diff --git a/docs/modules/industries.md b/docs/modules/industries.md index 99ed5524..62f8d14c 100644 --- a/docs/modules/industries.md +++ b/docs/modules/industries.md @@ -1,3 +1,138 @@ # Industries -TODO +## Назначение + +Industries - справочник отраслей, который используется для классификации +проектов и фильтрации проектных данных. + +Модуль не содержит сложной бизнес-логики: его основная задача - хранить +`Industry` и отдавать список отраслей клиентам и другим модулям. + +## Статус модуля + +Модуль рабочий и подключен в публичный API через `/industries/`. + +Есть небольшой долг: + +- в модели нет уникальности `name`, поэтому одинаковые отрасли можно создать + несколько раз. + +## Основные возможности + +- просмотр списка отраслей; +- просмотр одной отрасли; +- создание отрасли staff-пользователем; +- обновление отрасли staff-пользователем; +- удаление отрасли staff-пользователем; +- управление отраслями через Django admin. + +## Архитектура + +- `industries/models.py` - модель `Industry`. +- `industries/serializers.py` - `IndustrySerializer`. +- `industries/views.py` - list/create и detail/update/delete endpoints. +- `industries/urls.py` - routes модуля. +- `industries/admin.py` - регистрация модели в Django admin. +- `industries/tests/` - regression-тесты и helpers модуля. + +## Основные сущности + +### Industry + +`Industry` описывает отрасль проекта. + +Поля: + +- `id` - идентификатор отрасли; +- `name` - название отрасли, максимум 256 символов; +- `datetime_created` - дата создания. + +Сортировка по умолчанию: `name`. + +Строковое представление: + +```text +Industry - name +``` + +## API + +- `GET /industries/` - список отраслей. +- `POST /industries/` - создание отрасли. +- `GET /industries//` - детали отрасли. +- `PUT /industries//` - полное обновление отрасли. +- `PATCH /industries//` - частичное обновление отрасли. +- `DELETE /industries//` - удаление отрасли. + +Permissions: + +- read operations доступны всем пользователям; +- anonymous write operations возвращают `401`; +- authenticated non-staff write operations возвращают `403`; +- staff write operations разрешены; +- фактически используется общий permission `core.permissions.IsStaffOrReadOnly`. + +Response contract: + +```json +{ + "id": 1, + "name": "IT", + "datetime_created": "2026-01-01T00:00:00Z" +} +``` + +## Основные сценарии + +### Пользователь выбирает отрасль проекта + +Frontend получает список отраслей через `GET /industries/` и использует `id` +отрасли при создании или обновлении проекта. + +### Пользователь фильтрует проекты по отрасли + +Проекты фильтруются по `industry` через модуль `projects`. Сам справочник +только хранит отрасли и не содержит собственной логики фильтрации проектов. + +### Администратор управляет справочником + +Staff-пользователь может создавать, обновлять и удалять отрасли через API или +Django admin. + +## Связи с другими модулями + +- `projects` - `Project.industry` ссылается на `Industry`. +- `vacancy` - detail вакансии отдает проект вместе с отраслью проекта. +- `partner_programs` - serializers программных проектов отдают отрасль проекта. +- `project_rates` - serializers оценок проектов отдают отрасль проекта. +- `news`, `projects` и другие тестовые helpers создают отрасли для связанных + сценариев. + +## Ограничения и риски + +- `Industry.name` не уникален. Сейчас можно создать несколько отраслей с + одинаковым названием. +- При удалении отрасли у связанных проектов `Project.industry` становится + `NULL`, потому что связь настроена через `on_delete=SET_NULL`. +- Удаление или переименование отрасли может повлиять на фильтрацию и отображение + проектов на frontend. + +## Тесты + +Текущие тесты лежат в `industries/tests/`. + +Проверяется: + +- публичный список отраслей через реальный URL; +- публичный detail отрасли через реальный URL; +- 404 для отсутствующей отрасли; +- создание отрасли staff-пользователем; +- запрет создания отрасли anonymous-пользователем; +- запрет создания и обновления отрасли обычным пользователем; +- обновление отрасли staff-пользователем; +- удаление отрасли staff-пользователем; +- отвязка проектов от удаленной отрасли через `on_delete=SET_NULL`; +- ошибка при отсутствующем `name`; +- ошибка при пустом `name`; +- ошибка при слишком длинном `name`; +- строковое представление `Industry`. diff --git a/docs/modules/invites.md b/docs/modules/invites.md index dcca3981..48efddfa 100644 --- a/docs/modules/invites.md +++ b/docs/modules/invites.md @@ -1,3 +1,219 @@ # Invites -TODO +## Назначение + +Invites отвечает за приглашения пользователей в команду проекта. + +Модуль закрывает сценарий, когда лидер проекта приглашает пользователя на роль в +проекте, а пользователь принимает или отклоняет приглашение. + +## Статус модуля + +Модуль рабочий и подключен в публичный API через `/invites/`. + +Приглашения доступны приглашенному пользователю, лидеру проекта и +staff/superuser. Изменять или удалять приглашение может лидер проекта. Принять +или отклонить приглашение может приглашенный пользователь. + +## Основные возможности + +- создание приглашения лидером проекта; +- просмотр списка активных приглашений; +- фильтрация приглашений по проекту и пользователю; +- просмотр, обновление и удаление приглашения; +- принятие приглашения пользователем; +- отклонение приглашения пользователем; +- автоматическое добавление пользователя в collaborators проекта после принятия; +- запрет приглашения лидера проекта; +- запрет приглашения пользователя, который уже состоит в проекте; +- запрет повторного активного приглашения в тот же проект; +- проверка участия пользователя в партнерской программе, если проект привязан к + программе. + +## Архитектура + +- `invites/models.py` - модель `Invite`. +- `invites/serializers.py` - serializers создания и чтения приглашений. +- `invites/views.py` - API endpoints и основная orchestration logic. +- `invites/filters.py` - фильтры списка приглашений. +- `invites/managers.py` - queryset helper для списка. +- `invites/querysets.py` - queryset видимых приглашений для текущего + пользователя. +- `invites/permissions.py` - object-level permissions для detail и + accept/decline. +- `invites/urls.py` - routes модуля. +- `invites/admin.py` - регистрация приглашений в Django admin. +- `invites/tests/` - regression-тесты и helpers модуля. + +## Основные сущности + +### Invite + +`Invite` хранит приглашение пользователя в проект. + +Поля: + +- `project` - проект, в который приглашают пользователя; +- `user` - приглашенный пользователь; +- `motivational_letter` - текст приглашения; +- `role` - роль пользователя в проекте после принятия; +- `specialization` - специализация пользователя после принятия; +- `is_accepted` - статус приглашения: + - `None` - ожидает решения; + - `True` - принято; + - `False` - отклонено; +- `datetime_created` - дата создания; +- `datetime_updated` - дата обновления. + +Сортировка по умолчанию: новые приглашения выше. + +## API + +- `GET /invites/` - список активных приглашений. +- `POST /invites/` - создание приглашения. +- `GET /invites//` - детали приглашения. +- `PUT /invites//` - полное обновление приглашения. +- `PATCH /invites//` - частичное обновление приглашения. +- `DELETE /invites//` - удаление приглашения. +- `POST /invites//accept/` - принять приглашение. +- `POST /invites//decline/` - отклонить приглашение. + +Фильтры списка: + +- `project` - фильтр по проекту; +- `user` - фильтр по пользователю; +- `user=any` - staff-only фильтр для отключения пользовательского фильтра. + +Список всегда ограничен активными приглашениями: `is_accepted IS NULL`. + +## Доступ и права + +- список приглашений доступен только authenticated пользователю; +- приглашенный пользователь видит свои приглашения; +- лидер проекта видит приглашения своего проекта через `project=`; +- staff/superuser может получить все активные приглашения через `user=any`; +- `user=` работает только внутри уже разрешенной области видимости; +- `user=any` запрещен для обычных пользователей и лидеров проекта; +- detail приглашения видят только приглашенный пользователь, лидер проекта и + staff/superuser; +- обновить или удалить приглашение может только лидер проекта; +- принять или отклонить приглашение может только приглашенный пользователь; +- повторный `accept` или `decline` по уже обработанному приглашению возвращает + `409 Conflict` и не меняет финальный статус. + +## Основные сценарии + +### 1. Лидер проекта создает приглашение + +Лидер проекта отправляет `POST /invites/` с проектом, пользователем, ролью, +специализацией и текстом приглашения. + +При создании: + +- serializer проверяет, что пользователь не является лидером проекта; +- serializer проверяет, что пользователь еще не collaborator проекта; +- serializer запрещает повторное активное приглашение в тот же проект; +- если проект привязан к партнерской программе, пользователь должен быть + участником этой программы; +- view отдельно проверяет, что текущий пользователь является лидером проекта. + +### 2. Пользователь видит свои приглашения + +Пользователь получает список через `GET /invites/`. + +По умолчанию фильтр `user` подставляется из `request.user`, поэтому +приглашенный пользователь видит свои активные приглашения. Лидер проекта +получает активные приглашения проекта через `GET /invites/?project=`. Staff +или superuser может работать со всем списком через `GET /invites/?user=any`. + +### 3. Пользователь принимает приглашение + +Пользователь вызывает `POST /invites//accept/`. + +При успешном принятии: + +- проверяется, что приглашение принимает именно приглашенный пользователь; +- если приглашение уже принято или отклонено, возвращается conflict; +- пользователь добавляется в `Collaborator` проекта; +- в collaborator переносятся `role` и `specialization` из приглашения; +- приглашение получает `is_accepted=True`. + +### 4. Пользователь отклоняет приглашение + +Пользователь вызывает `POST /invites//decline/`. + +При успешном отклонении: + +- проверяется, что приглашение отклоняет именно приглашенный пользователь; +- если приглашение уже принято или отклонено, возвращается conflict; +- приглашение получает `is_accepted=False`. + +### 5. Приглашение влияет на доступ к проекту + +В `projects.permissions` приглашенный пользователь считается вовлеченным в +проект. Это дает доступ к непубличному или draft-проекту до принятия +приглашения. + +## Связи с другими модулями + +- `projects` - приглашение всегда связано с проектом; после принятия создается + `Collaborator`. +- `users` - приглашение связано с приглашенным пользователем и лидером проекта. +- `partner_programs` - если проект связан с партнерской программой, приглашать + можно только участника этой программы. +- `projects.permissions` - invite участвует в проверках доступа к непубличным и + draft-проектам. + +## Ограничения и риски + +- Проверка участия в партнерской программе смотрит только первую связь + `project.program_links.first()`. Если проект может быть связан с несколькими + программами, этот контракт нужно уточнить. +- Нет DB-level constraint для запрета нескольких активных приглашений одного + пользователя в один проект. Сейчас это защищено только serializer validation. +- Вся orchestration logic находится во views/serializers, отдельного service + layer пока нет. + +## Тесты + +Текущие тесты лежат в `invites/tests/`. + +Проверяется: + +- создание приглашения лидером проекта; +- создание приглашения без motivational letter; +- запрет создания приглашения пользователем, который не является лидером + проекта; +- ошибка при пустом payload; +- запрет приглашения лидера проекта; +- запрет приглашения существующего collaborator; +- запрет повторного активного приглашения в тот же проект; +- запрет приглашения пользователя, который не является участником программы + проекта; +- создание приглашения для участника программы, если проект привязан к + программе; +- список активных приглашений текущего пользователя; +- фильтр списка по проекту: лидер проекта видит все активные приглашения этого + проекта; +- фильтр списка по пользователю внутри разрешенной области видимости; +- `user=any` доступен только staff/superuser; +- запрет anonymous list; +- права чтения detail: приглашенный пользователь, лидер проекта и staff имеют + доступ, outsider и anonymous не имеют; +- права update/delete: только лидер проекта может менять или удалять + приглашение; +- принятие приглашения приглашенным пользователем с созданием collaborator и + переносом `role` / `specialization`; +- отклонение приглашения приглашенным пользователем; +- запрет accept/decline чужим пользователем; +- запрет accept/decline лидером проекта вместо приглашенного пользователя; +- conflict при повторном accept уже обработанного приглашения; +- conflict при повторном decline уже обработанного приглашения; +- защита decline после accept: статус остается принятым, collaborator остается + в проекте; +- строковое представление `Invite`. + +Сейчас не покрыты: + +- DB-level constraint для повторных активных приглашений; +- сценарий проекта, связанного с несколькими партнерскими программами. diff --git a/industries/permissions.py b/industries/permissions.py deleted file mode 100644 index 230c5bee..00000000 --- a/industries/permissions.py +++ /dev/null @@ -1,17 +0,0 @@ -from rest_framework.permissions import BasePermission, SAFE_METHODS - - -class IndustryPermission(BasePermission): - """ - Allows access to update only to staff users. - """ - - def has_permission(self, request, view) -> bool: - if request.method in SAFE_METHODS or request.user and request.user.is_staff: - return True - return False - - def has_object_permission(self, request, view, obj) -> bool: - if request.method in SAFE_METHODS or request.user and request.user.is_staff: - return True - return False diff --git a/industries/tests.py b/industries/tests.py deleted file mode 100644 index 13ee3b2c..00000000 --- a/industries/tests.py +++ /dev/null @@ -1,103 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIRequestFactory, force_authenticate -from tests.constants import USER_CREATE_DATA -from users.models import CustomUser -from users.views import UserList - -from industries.models import Industry -from industries.views import IndustryDetail, IndustryList - - -class IndustryTestCase(TestCase): - """Tests for industries+""" - - def setUp(self): - self.factory = APIRequestFactory() - - self.user_list_view = UserList.as_view() - - self.industry_list_view = IndustryList.as_view() - self.industry_detail_view = IndustryDetail.as_view() - - self.INDUSTRY_NAME = "Test Industry" - self.CREATE_DATA = { - "name": self.INDUSTRY_NAME, - } - - def test_industry_creation(self): - user = self._user_create() - request = self.factory.post("industries/", self.CREATE_DATA) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data["name"], self.INDUSTRY_NAME) - - def test_industry_creation_with_too_long_name(self): - user = self._user_create() - request = self.factory.post("industries/", {"name": "too_long_string_" * 257}) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_industry_creation_with_empty_name(self): - user = self._user_create() - request = self.factory.post("industries/", {"name": ""}) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_industry_creation_with_wrong_data(self): - user = self._user_create() - request = self.factory.post("industries/", {"wrong_name": "Wrong value"}) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_industry_creation_with_empty_data(self): - user = self._user_create() - request = self.factory.post("industries/", {}) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_industry_update(self): - user = self._user_create() - request = self.factory.post("industries/", self.CREATE_DATA) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - - industry_id = response.data["id"] - industry = Industry.objects.get(id=industry_id) - - request = self.factory.patch(f"industries/{industry.pk}/", {"name": "Test2"}) - force_authenticate(request, user=user) - response = self.industry_detail_view(request, pk=industry.pk) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["name"], "Test2") - - def test_industry_update_with_wrong_data(self): - user = self._user_create() - request = self.factory.post("industries/", self.CREATE_DATA) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - - industry_id = response.data["id"] - industry = Industry.objects.get(id=industry_id) - - request = self.factory.patch(f"industries/{industry.pk}/", {"name": ""}) - force_authenticate(request, user=user) - response = self.industry_detail_view(request, pk=industry.pk) - - self.assertEqual(response.status_code, 400) - - def _user_create(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - user.is_active = True - user.is_staff = True - user.save() - return user diff --git a/industries/tests/__init__.py b/industries/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/industries/tests/helpers.py b/industries/tests/helpers.py new file mode 100644 index 00000000..edb31c80 --- /dev/null +++ b/industries/tests/helpers.py @@ -0,0 +1,43 @@ +from datetime import date +from uuid import uuid4 + +from industries.models import Industry +from projects.models import Project +from users.models import CustomUser + + +def unique_suffix() -> str: + return uuid4().hex[:8] + + +def create_user(*, prefix: str = "industry-user", is_staff: bool = False): + user = CustomUser.objects.create_user( + email=f"{prefix}-{unique_suffix()}@example.com", + password="very_strong_password", + first_name="Иван", + last_name="Иванов", + birthday=date(2000, 1, 1), + ) + user.is_active = True + user.is_staff = is_staff + user.save(update_fields=["is_active", "is_staff"]) + return user + + +def create_industry(*, name: str = "Industry") -> Industry: + return Industry.objects.create(name=f"{name} {unique_suffix()}") + + +def create_project_with_industry( + *, + industry: Industry, + leader: CustomUser | None = None, +) -> Project: + return Project.objects.create( + leader=leader or create_user(prefix="project-leader"), + name=f"Project {unique_suffix()}", + description="Проект с отраслью", + industry=industry, + draft=False, + is_public=True, + ) diff --git a/industries/tests/test_industry_api.py b/industries/tests/test_industry_api.py new file mode 100644 index 00000000..32ea66b1 --- /dev/null +++ b/industries/tests/test_industry_api.py @@ -0,0 +1,162 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from industries.models import Industry +from industries.tests.helpers import ( + create_industry, + create_project_with_industry, + create_user, +) + + +class IndustryReadAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_anonymous_user_can_get_industry_list_ordered_by_name(self): + beta = create_industry(name="Beta") + alpha = create_industry(name="Alpha") + + response = self.client.get("/industries/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data], + [alpha.id, beta.id], + ) + + def test_anonymous_user_can_get_industry_detail(self): + industry = create_industry(name="Robotics") + + response = self.client.get(f"/industries/{industry.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], industry.id) + self.assertEqual(response.data["name"], industry.name) + self.assertIn("datetime_created", response.data) + + def test_missing_industry_returns_404(self): + response = self.client.get("/industries/999999/") + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class IndustryWriteAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_staff_user_can_create_industry(self): + staff = create_user(prefix="staff", is_staff=True) + self.client.force_authenticate(staff) + + response = self.client.post( + "/industries/", + {"name": "Новая отрасль"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["name"], "Новая отрасль") + self.assertTrue(Industry.objects.filter(name="Новая отрасль").exists()) + + def test_anonymous_user_must_authenticate_to_create_industry(self): + response = self.client.post( + "/industries/", + {"name": "Закрытая отрасль"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertFalse(Industry.objects.filter(name="Закрытая отрасль").exists()) + + def test_regular_user_cannot_create_industry(self): + user = create_user(prefix="regular") + self.client.force_authenticate(user) + + response = self.client.post( + "/industries/", + {"name": "Закрытая отрасль"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(Industry.objects.filter(name="Закрытая отрасль").exists()) + + def test_staff_user_can_update_industry(self): + staff = create_user(prefix="staff", is_staff=True) + industry = create_industry(name="Old") + self.client.force_authenticate(staff) + + response = self.client.patch( + f"/industries/{industry.id}/", + {"name": "Updated industry"}, + format="json", + ) + + industry.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(industry.name, "Updated industry") + + def test_regular_user_cannot_update_industry(self): + user = create_user(prefix="regular") + industry = create_industry(name="Protected") + self.client.force_authenticate(user) + + response = self.client.patch( + f"/industries/{industry.id}/", + {"name": "Unexpected"}, + format="json", + ) + + industry.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertNotEqual(industry.name, "Unexpected") + + def test_staff_user_can_delete_industry_and_detach_projects(self): + staff = create_user(prefix="staff", is_staff=True) + industry = create_industry(name="Temporary") + project = create_project_with_industry(industry=industry) + self.client.force_authenticate(staff) + + response = self.client.delete(f"/industries/{industry.id}/") + + project.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Industry.objects.filter(pk=industry.pk).exists()) + self.assertIsNone(project.industry) + + def test_name_is_required_on_create(self): + staff = create_user(prefix="staff", is_staff=True) + self.client.force_authenticate(staff) + + response = self.client.post("/industries/", {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("name", response.data) + + def test_name_cannot_be_blank(self): + staff = create_user(prefix="staff", is_staff=True) + self.client.force_authenticate(staff) + + response = self.client.post( + "/industries/", + {"name": ""}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("name", response.data) + + def test_name_cannot_exceed_256_symbols(self): + staff = create_user(prefix="staff", is_staff=True) + self.client.force_authenticate(staff) + + response = self.client.post( + "/industries/", + {"name": "x" * 257}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("name", response.data) diff --git a/industries/tests/test_models.py b/industries/tests/test_models.py new file mode 100644 index 00000000..f07070d1 --- /dev/null +++ b/industries/tests/test_models.py @@ -0,0 +1,10 @@ +from django.test import TestCase + +from industries.tests.helpers import create_industry + + +class IndustryModelTests(TestCase): + def test_string_representation_contains_id_and_name(self): + industry = create_industry(name="Fintech") + + self.assertEqual(str(industry), f"Industry<{industry.id}> - {industry.name}") diff --git a/invites/filters.py b/invites/filters.py index 635a3cf2..9c62215d 100644 --- a/invites/filters.py +++ b/invites/filters.py @@ -1,9 +1,16 @@ from django_filters import rest_framework as filters +from rest_framework.exceptions import PermissionDenied from invites.models import Invite from vacancy.filters import project_id_filter +def _first_filter_value(value): + if isinstance(value, list): + return value[0] if value else None + return value + + class InviteFilter(filters.FilterSet): """Filter for Invite @@ -12,13 +19,15 @@ class InviteFilter(filters.FilterSet): Parameters to filter by: project (int), user (default to request.user if not set otherwise) (int), - user=any (disable user filter) + user=any (available only for staff; disables user filter inside already + visible queryset) Examples: ?project=1 equals to .filter(project_id=1) (no params passed) equals to .filter(user=request.user) ?user=4 equals to .filter(user_id=4) - ?project=1&user=any equals to .filter(project_id=1) + ?project=1 for project leader equals to all active project invites + ?user=any for staff equals to all active invites """ def __init__(self, *args, **kwargs): @@ -26,17 +35,19 @@ def __init__(self, *args, **kwargs): self.data = dict(self.data) request = kwargs.get("request") if request and request.user.is_authenticated: - user_value = self.data.get("user") - if isinstance(user_value, list): - user_value = user_value[0] if user_value else None - if user_value is None: + user_value = _first_filter_value(self.data.get("user")) + project_value = _first_filter_value(self.data.get("project")) + if user_value is None and project_value in (None, ""): self.data["user"] = request.user.id - @staticmethod - def filter_user(queryset, name, value): - if isinstance(value, list): - value = value[0] if value else None - if value in (None, "", "any"): + def filter_user(self, queryset, name, value): + value = _first_filter_value(value) + if value == "any": + user = getattr(getattr(self, "request", None), "user", None) + if not user or not (user.is_staff or user.is_superuser): + raise PermissionDenied("Фильтр user=any доступен только staff.") + return queryset + if value in (None, ""): return queryset return queryset.filter(user_id=value) diff --git a/invites/permissions.py b/invites/permissions.py new file mode 100644 index 00000000..804028ca --- /dev/null +++ b/invites/permissions.py @@ -0,0 +1,46 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +def can_view_invite(user, invite) -> bool: + if not user or not user.is_authenticated: + return False + + return ( + user.is_staff + or user.is_superuser + or invite.user_id == user.id + or invite.project.leader_id == user.id + ) + + +def can_manage_invite(user, invite) -> bool: + if not user or not user.is_authenticated: + return False + + return invite.project.leader_id == user.id + + +def can_decide_invite(user, invite) -> bool: + if not user or not user.is_authenticated: + return False + + return invite.user_id == user.id + + +class InviteDetailPermission(BasePermission): + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated) + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return can_view_invite(request.user, obj) + + return can_manage_invite(request.user, obj) + + +class InviteDecisionPermission(BasePermission): + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated) + + def has_object_permission(self, request, view, obj): + return can_decide_invite(request.user, obj) diff --git a/invites/querysets.py b/invites/querysets.py new file mode 100644 index 00000000..55c2bc4c --- /dev/null +++ b/invites/querysets.py @@ -0,0 +1,15 @@ +from django.db.models import Q, QuerySet + +from invites.models import Invite + + +def get_visible_invites_queryset(user) -> QuerySet[Invite]: + queryset = Invite.objects.get_invite_for_list_view() + + if not user or not user.is_authenticated: + return queryset.none() + + if user.is_staff or user.is_superuser: + return queryset + + return queryset.filter(Q(user_id=user.id) | Q(project__leader_id=user.id)) diff --git a/invites/tests.py b/invites/tests.py deleted file mode 100644 index 5f4b3d21..00000000 --- a/invites/tests.py +++ /dev/null @@ -1,254 +0,0 @@ -from datetime import timedelta - -from django.test import TestCase -from django.utils import timezone -from rest_framework.test import APIRequestFactory, force_authenticate - -from partner_programs.models import PartnerProgram, PartnerProgramProject -from projects.models import Collaborator, Project -from tests.constants import USER_CREATE_DATA - -from users.views import UserList -from users.models import CustomUser -from invites.views import InviteList, InviteDetail, InviteAccept, InviteDecline -from invites.models import Invite -from industries.models import Industry -from projects.views import ProjectList, ProjectDetail - - -class InvitesTestCase(TestCase): - def setUp(self) -> None: - self.factory = APIRequestFactory() - - self.user_list_view = UserList.as_view() - - self.invite_list_view = InviteList.as_view() - self.invite_detail_view = InviteDetail.as_view() - - self.project_list_view = ProjectList.as_view() - self.project_detail_view = ProjectDetail.as_view() - - self.invite_create_data = { - "project": "Test", - "user": None, - "motivational_letter": "hello", - "role": "Developer", - } - - self.project_create_data = { - "name": "Test", - "description": "Test", - "industry": Industry.objects.create(name="Test").id, - "draft": False, - } - - def test_invites_creation(self): - user_main = self._user_create("example@gmail.com") - user2 = self._user_create("example2@gmail.com") - project = self._project_create(user_main) - - create_user = self.invite_create_data.copy() - create_user["user"] = user2.id - create_user["project"] = project.id - request = self.factory.post("invites/", create_user, format="json") - force_authenticate(request, user=user_main) - - response = self.invite_list_view(request) - - self.assertEqual(response.status_code, 201) - self.assertEqual( - response.data["motivational_letter"], - create_user["motivational_letter"], - ) - self.assertEqual(response.data["project"]["id"], create_user["project"]) - self.assertEqual(response.data["role"], create_user["role"]) - self.assertIsNone(response.data["is_accepted"]) - - def test_invites_creation_with_empty_text(self): - user_main = self._user_create("example@gmail.com") - user2 = self._user_create("example2@gmail.com") - project = self._project_create(user_main) - - empty_text = self.invite_create_data.copy() - empty_text["user"] = user2.id - empty_text["project"] = project.id - empty_text["motivational_letter"] = None - request = self.factory.post("invites/", empty_text, format="json") - force_authenticate(request, user=user_main) - - response = self.invite_list_view(request) - - self.assertEqual(response.status_code, 201) - - def test_invites_update(self): - user_main = self._user_create("example@gmail.com") - user2 = self._user_create("example2@gmail.com") - project = self._project_create(user_main) - - updater = self.invite_create_data.copy() - updater["user"] = user2.id - updater["project"] = project.id - request = self.factory.post("invites/", updater, format="json") - force_authenticate(request, user=user_main) - - response = self.invite_list_view(request) - - invite_id = response.data["id"] - invite = Invite.objects.get(id=invite_id) - - request = self.factory.patch( - f"invites/{invite.pk}/", {"motivational_letter": "HELLO GUYS!"} - ) - force_authenticate(request, user=user_main) - response = self.invite_detail_view(request, pk=invite.pk) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["motivational_letter"], "HELLO GUYS!") - - def test_invites_creation_with_empty_data(self): - user_main = self._user_create("example@gmail.com") - - empty_data = {} - - request = self.factory.post("invites/", empty_data, format="json") - force_authenticate(request, user=user_main) - response = self.invite_list_view(request) - - self.assertEqual(response.status_code, 400) - - def test_invites_creation_for_existing_collaborator(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - project = self._project_create(sender) - Collaborator.objects.create(user=recipient, project=project, role="Developer") - - create_user = self.invite_create_data.copy() - create_user["user"] = recipient.id - create_user["project"] = project.id - request = self.factory.post("invites/", create_user, format="json") - force_authenticate(request, user=sender) - - response = self.invite_list_view(request) - - self.assertEqual(response.status_code, 400) - self.assertIn("user", response.data) - - def test_invites_creation_for_non_program_member(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - project = self._project_create(sender) - now = timezone.now() - program = PartnerProgram.objects.create( - name="Test program", - tag="test", - city="Moscow", - datetime_registration_ends=now + timedelta(days=1), - datetime_started=now, - datetime_finished=now + timedelta(days=30), - ) - PartnerProgramProject.objects.create( - partner_program=program, project=project - ) - - create_user = self.invite_create_data.copy() - create_user["user"] = recipient.id - create_user["project"] = project.id - request = self.factory.post("invites/", create_user, format="json") - force_authenticate(request, user=sender) - - response = self.invite_list_view(request) - - self.assertEqual(response.status_code, 400) - self.assertIn("user", response.data) - - def test_accept_invite_by_intended_user(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - project = self._project_create(sender) - - invite = self._create_invite(sender, recipient, project) - - request = self.factory.post(f"/invites/{invite.id}/accept/") - force_authenticate(request, user=recipient) - accept_response = InviteAccept.as_view()(request, pk=invite.id) - - self.assertEqual(accept_response.status_code, 200) - invite.refresh_from_db() - self.assertTrue(invite.is_accepted) - - def test_decline_invite_by_intended_user(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - project = self._project_create(sender) - - invite = self._create_invite(sender, recipient, project) - - request = self.factory.post(f"/invites/{invite.id}/decline/") - force_authenticate(request, user=recipient) - decline_response = InviteDecline.as_view()(request, pk=invite.id) - - self.assertEqual(decline_response.status_code, 200) - invite.refresh_from_db() - self.assertFalse(invite.is_accepted) - - def test_accept_decline_invite_by_unintended_user(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - unintended_user = self._user_create("unintended@example.com") - project = self._project_create(sender) - - invite = self._create_invite(sender, recipient, project) - - accept_request = self.factory.post(f"/invites/{invite.id}/accept/") - force_authenticate(accept_request, user=unintended_user) - accept_response = InviteAccept.as_view()(accept_request, pk=invite.id) - - decline_request = self.factory.post(f"/invites/{invite.id}/decline/") - force_authenticate(decline_request, user=unintended_user) - decline_response = InviteDecline.as_view()(decline_request, pk=invite.id) - - self.assertNotEqual(accept_response.status_code, 200) - self.assertNotEqual(decline_response.status_code, 200) - - def test_delete_invite_by_sender(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - project = self._project_create(sender) - - invite = self._create_invite(sender, recipient, project) - - request = self.factory.delete(f"/invites/{invite.id}/") - force_authenticate(request, user=sender) - delete_response = self.invite_detail_view(request, pk=invite.id) - - self.assertEqual(delete_response.status_code, 204) - self.assertFalse(Invite.objects.filter(pk=invite.id).exists()) - - def _project_create(self, user): - request = self.factory.post("projects/", self.project_create_data) - force_authenticate(request, user=user) - - response = self.project_list_view(request) - project_id = response.data["id"] - return Project.objects.get(pk=project_id) - - def _user_create(self, email): - tmp_create_data = USER_CREATE_DATA.copy() - tmp_create_data["email"] = email - request = self.factory.post("auth/users/", tmp_create_data) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - user.is_active = True - user.save() - return user - - def _create_invite(self, sender, recipient, project): - invite_data = self.invite_create_data.copy() - invite_data.update({"project": project.id, "user": recipient.id}) - - request = self.factory.post("/invites/", invite_data, format="json") - force_authenticate(request, user=sender) - response = self.invite_list_view(request) - - return Invite.objects.get(pk=response.data["id"]) diff --git a/invites/tests/__init__.py b/invites/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/invites/tests/helpers.py b/invites/tests/helpers.py new file mode 100644 index 00000000..0814bc17 --- /dev/null +++ b/invites/tests/helpers.py @@ -0,0 +1,133 @@ +from datetime import date, timedelta +from uuid import uuid4 + +from django.utils import timezone + +from industries.models import Industry +from invites.models import Invite +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from projects.models import Collaborator, Project +from users.models import CustomUser + + +def unique_suffix() -> str: + return uuid4().hex[:8] + + +def create_user(*, prefix: str = "invite-user", is_staff: bool = False) -> CustomUser: + user = CustomUser.objects.create_user( + email=f"{prefix}-{unique_suffix()}@example.com", + password="very_strong_password", + first_name="Иван", + last_name="Иванов", + birthday=date(2000, 1, 1), + ) + user.is_active = True + user.is_staff = is_staff + user.save(update_fields=["is_active", "is_staff"]) + return user + + +def create_industry(*, name: str = "Industry") -> Industry: + return Industry.objects.create(name=f"{name} {unique_suffix()}") + + +def create_project( + *, + leader: CustomUser | None = None, + name: str = "Invite project", + draft: bool = False, + is_public: bool = True, +) -> Project: + return Project.objects.create( + leader=leader or create_user(prefix="project-leader"), + name=f"{name} {unique_suffix()}", + description="Проект для приглашений", + industry=create_industry(), + draft=draft, + is_public=is_public, + ) + + +def invite_payload(project: Project, user: CustomUser, **overrides) -> dict: + payload = { + "project": project.id, + "user": user.id, + "motivational_letter": "Хотим пригласить вас в команду", + "role": "Developer", + "specialization": "Backend", + } + payload.update(overrides) + return payload + + +def create_invite( + *, + project: Project | None = None, + user: CustomUser | None = None, + is_accepted: bool | None = None, + role: str | None = "Developer", + specialization: str | None = "Backend", + motivational_letter: str | None = "Хотим пригласить вас в команду", +) -> Invite: + return Invite.objects.create( + project=project or create_project(), + user=user or create_user(prefix="recipient"), + is_accepted=is_accepted, + role=role, + specialization=specialization, + motivational_letter=motivational_letter, + ) + + +def add_collaborator( + *, + project: Project, + user: CustomUser, + role: str = "Developer", + specialization: str | None = "Backend", +) -> Collaborator: + return Collaborator.objects.create( + project=project, + user=user, + role=role, + specialization=specialization, + ) + + +def create_program() -> PartnerProgram: + now = timezone.now() + return PartnerProgram.objects.create( + name=f"Program {unique_suffix()}", + tag=f"program-{unique_suffix()}", + city="Moscow", + datetime_registration_ends=now + timedelta(days=1), + datetime_started=now, + datetime_finished=now + timedelta(days=30), + ) + + +def link_project_to_program( + *, + project: Project, + program: PartnerProgram | None = None, +) -> PartnerProgram: + program = program or create_program() + PartnerProgramProject.objects.create(partner_program=program, project=project) + return program + + +def add_user_to_program( + *, + user: CustomUser, + program: PartnerProgram, +) -> PartnerProgramUserProfile: + return PartnerProgramUserProfile.objects.create( + user=user, + partner_program=program, + partner_program_data={}, + ) diff --git a/invites/tests/test_invite_api.py b/invites/tests/test_invite_api.py new file mode 100644 index 00000000..faf8508f --- /dev/null +++ b/invites/tests/test_invite_api.py @@ -0,0 +1,277 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from invites.models import Invite +from invites.tests.helpers import ( + add_collaborator, + add_user_to_program, + create_invite, + create_project, + create_user, + invite_payload, + link_project_to_program, +) + + +class InviteCreateAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_project_leader_can_create_invite(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient, role="Designer"), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["project"]["id"], project.id) + self.assertEqual(response.data["user"]["id"], recipient.id) + self.assertEqual(response.data["sender"]["id"], leader.id) + self.assertEqual(response.data["role"], "Designer") + self.assertIsNone(response.data["is_accepted"]) + + def test_project_leader_can_create_invite_without_motivational_letter(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient, motivational_letter=None), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIsNone(response.data["motivational_letter"]) + + def test_non_leader_cannot_create_invite(self): + leader = create_user(prefix="leader") + outsider = create_user(prefix="outsider") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + self.client.force_authenticate(outsider) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(Invite.objects.exists()) + + def test_create_invite_requires_payload(self): + leader = create_user(prefix="leader") + self.client.force_authenticate(leader) + + response = self.client.post("/invites/", {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Invite.objects.exists()) + + def test_cannot_invite_project_leader(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, leader), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("user", response.data) + + def test_cannot_invite_existing_collaborator(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + add_collaborator(project=project, user=recipient) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("user", response.data) + + def test_cannot_create_duplicate_active_invite(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + create_invite(project=project, user=recipient) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(Invite.objects.filter(project=project, user=recipient).count(), 1) + + def test_program_project_invite_requires_program_membership(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + link_project_to_program(project=project) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("user", response.data) + + def test_program_member_can_be_invited_to_program_project(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + program = link_project_to_program(project=project) + add_user_to_program(user=recipient, program=program) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["user"]["id"], recipient.id) + + +class InviteListFilterAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_list_returns_current_user_active_invites_by_default(self): + user = create_user(prefix="recipient") + own_invite = create_invite(user=user) + create_invite() + create_invite(user=user, is_accepted=True) + create_invite(user=user, is_accepted=False) + self.client.force_authenticate(user) + + response = self.client.get("/invites/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [own_invite.id]) + + def test_list_can_filter_by_project(self): + user = create_user(prefix="recipient") + project = create_project() + target_invite = create_invite(project=project, user=user) + create_invite(user=user) + self.client.force_authenticate(user) + + response = self.client.get("/invites/", {"project": project.id}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [target_invite.id]) + + def test_user_filter_does_not_expose_foreign_invites(self): + user = create_user(prefix="recipient") + other_user = create_user(prefix="other") + create_invite(user=other_user) + self.client.force_authenticate(user) + + response = self.client.get("/invites/", {"user": other_user.id}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, []) + + def test_user_any_filter_is_forbidden_for_regular_user(self): + user = create_user(prefix="recipient") + create_invite(user=user) + create_invite() + create_invite(is_accepted=True) + self.client.force_authenticate(user) + + response = self.client.get("/invites/", {"user": "any"}) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_project_leader_can_list_project_invites_by_project_filter(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + first_invite = create_invite(project=project) + second_invite = create_invite(project=project) + create_invite() + self.client.force_authenticate(leader) + + response = self.client.get("/invites/", {"project": project.id}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + {item["id"] for item in response.data}, + {first_invite.id, second_invite.id}, + ) + + def test_project_leader_cannot_use_user_any_filter(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + create_invite(project=project) + self.client.force_authenticate(leader) + + response = self.client.get( + "/invites/", + {"project": project.id, "user": "any"}, + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_project_leader_can_filter_project_invites_by_invited_user(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + recipient = create_user(prefix="recipient") + target_invite = create_invite(project=project, user=recipient) + create_invite(project=project) + create_invite() + self.client.force_authenticate(leader) + + response = self.client.get( + "/invites/", + {"project": project.id, "user": recipient.id}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [target_invite.id]) + + def test_staff_can_list_all_active_invites_with_user_any(self): + staff = create_user(prefix="staff", is_staff=True) + first_invite = create_invite() + second_invite = create_invite() + create_invite(is_accepted=True) + self.client.force_authenticate(staff) + + response = self.client.get("/invites/", {"user": "any"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + {item["id"] for item in response.data}, + {first_invite.id, second_invite.id}, + ) + + def test_anonymous_user_cannot_list_invites(self): + create_invite() + + response = self.client.get("/invites/") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/invites/tests/test_invite_decisions.py b/invites/tests/test_invite_decisions.py new file mode 100644 index 00000000..0af8a681 --- /dev/null +++ b/invites/tests/test_invite_decisions.py @@ -0,0 +1,126 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from projects.models import Collaborator +from invites.tests.helpers import create_invite, create_user + + +class InviteDecisionAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_invited_user_can_accept_invite_and_become_collaborator(self): + recipient = create_user(prefix="recipient") + invite = create_invite( + user=recipient, + role="Analyst", + specialization="Market research", + ) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/accept/") + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(invite.is_accepted) + self.assertTrue( + Collaborator.objects.filter( + project=invite.project, + user=recipient, + role="Analyst", + specialization="Market research", + ).exists() + ) + + def test_invited_user_can_decline_invite(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(invite.is_accepted) + + def test_other_user_cannot_accept_or_decline_invite(self): + invite = create_invite() + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + accept_response = self.client.post(f"/invites/{invite.id}/accept/") + decline_response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(accept_response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(decline_response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIsNone(invite.is_accepted) + + def test_project_leader_cannot_accept_or_decline_invite(self): + invite = create_invite() + self.client.force_authenticate(invite.project.leader) + + accept_response = self.client.post(f"/invites/{invite.id}/accept/") + decline_response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(accept_response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(decline_response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIsNone(invite.is_accepted) + + def test_accepting_already_accepted_invite_returns_conflict(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient, is_accepted=True) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/accept/") + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + def test_accepting_declined_invite_returns_conflict(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient, is_accepted=False) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/accept/") + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + def test_declining_already_accepted_invite_returns_conflict(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient, is_accepted=True) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertTrue(invite.is_accepted) + + def test_declining_already_declined_invite_returns_conflict(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient, is_accepted=False) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertFalse(invite.is_accepted) + + def test_decline_after_accept_returns_conflict_and_keeps_collaborator(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient) + self.client.force_authenticate(recipient) + + accept_response = self.client.post(f"/invites/{invite.id}/accept/") + decline_response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(accept_response.status_code, status.HTTP_200_OK) + self.assertEqual(decline_response.status_code, status.HTTP_409_CONFLICT) + self.assertTrue(invite.is_accepted) + self.assertTrue( + Collaborator.objects.filter(project=invite.project, user=recipient).exists() + ) diff --git a/invites/tests/test_invite_detail_access.py b/invites/tests/test_invite_detail_access.py new file mode 100644 index 00000000..a6ab7c77 --- /dev/null +++ b/invites/tests/test_invite_detail_access.py @@ -0,0 +1,142 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from invites.models import Invite +from invites.tests.helpers import create_invite, create_user + + +class InviteDetailAccessTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_anonymous_user_cannot_read_invite_detail(self): + invite = create_invite() + + response = self.client.get(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_invited_user_can_read_invite_detail(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient) + self.client.force_authenticate(recipient) + + response = self.client.get(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], invite.id) + + def test_project_leader_can_read_invite_detail(self): + invite = create_invite() + self.client.force_authenticate(invite.project.leader) + + response = self.client.get(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], invite.id) + + def test_staff_can_read_invite_detail(self): + staff = create_user(prefix="staff", is_staff=True) + invite = create_invite() + self.client.force_authenticate(staff) + + response = self.client.get(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], invite.id) + + def test_outsider_cannot_read_invite_detail(self): + invite = create_invite() + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + response = self.client.get(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_project_leader_can_update_invite(self): + invite = create_invite(motivational_letter="Initial") + self.client.force_authenticate(invite.project.leader) + + response = self.client.patch( + f"/invites/{invite.id}/", + {"motivational_letter": "Changed by leader"}, + format="json", + ) + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(invite.motivational_letter, "Changed by leader") + + def test_invited_user_cannot_update_invite(self): + invite = create_invite(motivational_letter="Initial") + self.client.force_authenticate(invite.user) + + response = self.client.patch( + f"/invites/{invite.id}/", + {"motivational_letter": "Changed by recipient"}, + format="json", + ) + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(invite.motivational_letter, "Initial") + + def test_staff_cannot_update_invite(self): + staff = create_user(prefix="staff", is_staff=True) + invite = create_invite(motivational_letter="Initial") + self.client.force_authenticate(staff) + + response = self.client.patch( + f"/invites/{invite.id}/", + {"motivational_letter": "Changed by staff"}, + format="json", + ) + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(invite.motivational_letter, "Initial") + + def test_outsider_cannot_update_invite(self): + invite = create_invite(motivational_letter="Initial") + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + response = self.client.patch( + f"/invites/{invite.id}/", + {"motivational_letter": "Changed by outsider"}, + format="json", + ) + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(invite.motivational_letter, "Initial") + + def test_project_leader_can_delete_invite(self): + invite = create_invite() + self.client.force_authenticate(invite.project.leader) + + response = self.client.delete(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Invite.objects.filter(pk=invite.pk).exists()) + + def test_invited_user_cannot_delete_invite(self): + invite = create_invite() + self.client.force_authenticate(invite.user) + + response = self.client.delete(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(Invite.objects.filter(pk=invite.pk).exists()) + + def test_outsider_cannot_delete_invite(self): + invite = create_invite() + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + response = self.client.delete(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(Invite.objects.filter(pk=invite.pk).exists()) diff --git a/invites/tests/test_models.py b/invites/tests/test_models.py new file mode 100644 index 00000000..ea6b2d0a --- /dev/null +++ b/invites/tests/test_models.py @@ -0,0 +1,16 @@ +from django.test import TestCase + +from invites.tests.helpers import create_invite + + +class InviteModelTests(TestCase): + def test_string_representation_contains_project_and_user(self): + invite = create_invite() + + self.assertEqual( + str(invite), + ( + f'Invite from project "{invite.project.name}" ' + f"to {invite.user.get_full_name()}" + ), + ) diff --git a/invites/views.py b/invites/views.py index a365fff3..aeb5edfd 100644 --- a/invites/views.py +++ b/invites/views.py @@ -4,17 +4,23 @@ from invites.filters import InviteFilter from invites.models import Invite +from invites.permissions import InviteDecisionPermission, InviteDetailPermission +from invites.querysets import get_visible_invites_queryset from invites.serializers import InviteDetailSerializer, InviteListSerializer from projects.models import Collaborator class InviteList(generics.ListCreateAPIView): - queryset = Invite.objects.get_invite_for_list_view().filter(is_accepted__isnull=True) serializer_class = InviteDetailSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + permission_classes = [permissions.IsAuthenticated] filter_backends = (filters.DjangoFilterBackend,) filterset_class = InviteFilter + def get_queryset(self): + return get_visible_invites_queryset(self.request.user).filter( + is_accepted__isnull=True + ) + def create(self, request, *args, **kwargs): serializer = InviteListSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -33,28 +39,21 @@ def create(self, request, *args, **kwargs): class InviteDetail(generics.RetrieveUpdateDestroyAPIView): - queryset = Invite.objects.all() + queryset = Invite.objects.get_invite_for_list_view() serializer_class = InviteDetailSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + permission_classes = [InviteDetailPermission] class InviteAccept(generics.GenericAPIView): - queryset = Invite.objects.all() + queryset = Invite.objects.get_invite_for_list_view() serializer_class = InviteDetailSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + permission_classes = [InviteDecisionPermission] def post(self, request, *args, **kwargs): invite = self.get_object() # type: Invite - if invite.user != request.user: - return Response(status=status.HTTP_403_FORBIDDEN) - if invite.is_accepted is True: - return Response( - {"detail": "Invite has already been accepted."}, - status=status.HTTP_409_CONFLICT, - ) - if invite.is_accepted is False: + if invite.is_accepted is not None: return Response( - {"detail": "Invite has already been declined."}, + {"detail": "Invite has already been processed."}, status=status.HTTP_409_CONFLICT, ) # add user to project collaborators @@ -77,14 +76,17 @@ def post(self, request, *args, **kwargs): class InviteDecline(generics.GenericAPIView): - queryset = Invite.objects.all() + queryset = Invite.objects.get_invite_for_list_view() serializer_class = InviteDetailSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + permission_classes = [InviteDecisionPermission] def post(self, request, *args, **kwargs): invite = self.get_object() - if invite.user != request.user: - return Response(status=status.HTTP_403_FORBIDDEN) + if invite.is_accepted is not None: + return Response( + {"detail": "Invite has already been processed."}, + status=status.HTTP_409_CONFLICT, + ) invite.is_accepted = False invite.save() return Response(status=status.HTTP_200_OK)