diff --git a/docs/modules/project-rates.md b/docs/modules/project-rates.md index 519541ea..5b5e2fbc 100644 --- a/docs/modules/project-rates.md +++ b/docs/modules/project-rates.md @@ -1,3 +1,174 @@ # Project Rates -TODO +## Назначение + +Модуль `project_rates` отвечает за экспертную оценку проектов внутри +партнерских программ. + +Он используется, когда проект уже привязан к программе и эксперты должны +выставить оценки по критериям программы. Результаты оценок затем используются в +интерфейсе оценки и в Excel-выгрузках партнерской программы. + +## Статус модуля + +Модуль рабочий и используется вместе с `partner_programs`. Базовые сценарии +зафиксированы regression-тестами, бизнес-логика оценки вынесена в service +layer. При дальнейших изменениях важно сохранять текущий API-контракт и правила +доступа экспертов. + +## Основные возможности + +- хранение критериев оценки программы; +- выставление и обновление оценок проекта экспертом; +- проверка типов и диапазонов значений; +- ограничение количества экспертов, которые могут оценить один проект; +- режим распределенной оценки, когда эксперт видит и оценивает только + назначенные ему проекты; +- назначение проектов экспертам через Django admin; +- список проектов программы для оценки; +- фильтрация списка проектов по дополнительным полям программы; +- передача оценок в выгрузку результатов программы. + +## Архитектура + +- `project_rates/models.py` - критерии, оценки и назначения проектов экспертам. +- `project_rates/views.py` - API оценки проекта и списка проектов для оценки. +- `project_rates/services.py` - бизнес-логика выставления оценок, фильтрации + проектов для оценки и проверки лимитов. +- `project_rates/serializers.py` - request serializer оценки и response + serializer списка проектов. +- `project_rates/validators.py` - проверка типа значения и числовых границ + оценки. +- `project_rates/signals.py` - создание дефолтного критерия `Комментарий` для + новой программы. +- `project_rates/admin.py` - управление критериями, оценками и bulk-назначением + проектов экспертам. +- `project_rates/tests/` - regression-тесты API, модели назначений, + сериализации и критических правил. + +## Ключевые сущности + +- `Criteria` - критерий оценки проекта. Принадлежит конкретной партнерской + программе, имеет тип `str`, `int`, `float` или `bool`, а для числовых типов + может иметь `min_value` и `max_value`. +- `ProjectScore` - оценка проекта конкретным экспертом по конкретному критерию. + Уникальна по связке `criteria`, `user`, `project`. +- `ProjectExpertAssignment` - назначение проекта эксперту в программе. Нужно + для режима распределенной оценки. + +## API + +- `GET /rate-project/` - список проектов программы для оценки. +- `POST /rate-project/` - список проектов программы с фильтрами в + JSON body. +- `POST /rate-project/rate/` - выставление или обновление оценок + проекта. + +## Основные сценарии + +### 1. Эксперт открывает список проектов для оценки + +Эксперт запрашивает `GET /rate-project/`. + +API возвращает только проекты, привязанные к программе через +`PartnerProgramProject` и не находящиеся в черновике. + +Если у проекта уже есть оценки текущего эксперта, поле `criterias` возвращает +выставленные значения. Если текущий эксперт еще не оценивал проект, поле +`criterias` возвращает список критериев программы. + +### 2. Эксперт фильтрует проекты программы + +Эксперт может отправить `POST /rate-project/` с телом: + +```json +{ + "filters": { + "track": ["FinTech"] + } +} +``` + +Фильтры применяются к дополнительным полям программы, которые хранятся в +`PartnerProgramFieldValue`. + +### 3. Эксперт выставляет оценки + +Эксперт отправляет `POST /rate-project/rate/` со списком значений: + +```json +[ + { + "criterion_id": 1, + "value": "8" + } +] +``` + +API проверяет, что: + +- пользователь является экспертом; +- эксперт состоит в программе, к которой относятся критерии; +- все критерии в запросе относятся к одной программе; +- проект привязан к этой программе; +- значение соответствует типу критерия и числовым ограничениям; +- лимит `max_project_rates` не превышен. + +При повторной отправке оценки того же эксперта по тому же критерию значение +обновляется. + +### 4. Распределенная оценка + +Если у программы включено `is_distributed_evaluation`, эксперт видит и может +оценивать только проекты, назначенные ему через `ProjectExpertAssignment`. + +Назначение валидируется при сохранении: + +- эксперт должен состоять в программе; +- проект должен быть привязан к программе; +- количество назначений по проекту не должно превышать `max_project_rates`. + +Назначение нельзя удалить, если эксперт уже выставил оценку по этому проекту. + +### 5. Выгрузка результатов + +Модуль `partner_programs` использует `Criteria` и `ProjectScore` для подготовки +Excel-выгрузки оценок программы через `/programs//export-rates/`. + +## Связи с другими модулями + +- `partner_programs` - программа, связь проекта с программой, дополнительные + поля и выгрузка результатов. +- `projects` - оцениваемые проекты. +- `users` - эксперты программы и лидеры проектов. +- `vacancy.tasks.send_email` - уведомление лидера проекта после оценки. + +## Ограничения и правила + +- Значения оценок хранятся строкой, а тип проверяется через критерий. +- `ProjectScore.objects.bulk_create(..., update_conflicts=True)` обновляет + существующую оценку без создания дубля. +- `max_project_rates` ограничивает число разных экспертов, которые могут + оценить один проект в программе; текущий эксперт может обновить свою оценку. +- Дефолтный критерий `Комментарий` создается сигналом при создании программы. +- Основная бизнес-логика оценки вынесена в `project_rates/services.py`; views + отвечают за HTTP-контракт и преобразование ошибок в response. + +## Тесты + +Текущие regression-тесты проверяют: + +- обычную оценку проекта экспертом программы; +- обновление существующей оценки без дублей; +- запрет оценки экспертом, который не состоит в программе; +- запрет оценки проекта, который не привязан к программе; +- запрет оценки критериями из разных программ; +- ограничение `max_project_rates`; +- валидацию числовых границ и boolean-значений; +- список проектов программы без распределенной оценки; +- список проектов программы при распределенной оценке; +- фильтрацию проектов по дополнительным полям программы; +- response-поля `scored`, `rated_experts`, `rated_count`, `max_rates`; +- создание дефолтного критерия `Комментарий`; +- валидацию `ProjectExpertAssignment`; +- запрет удаления назначения после выставленной оценки. diff --git a/docs/modules/users.md b/docs/modules/users.md index 12d33799..89a2a4f1 100644 --- a/docs/modules/users.md +++ b/docs/modules/users.md @@ -1,3 +1,276 @@ # Users -TODO +## Назначение + +Users отвечает за учетные записи и профиль пользователя в Procollab: +регистрацию, подтверждение email, авторизацию, роли, публичные данные профиля, +достижения, навыки, CV, активность пользователя и связи пользователя с +проектами, программами, событиями и новостями. + +## Статус модуля + +Модуль рабочий, но находится в состоянии технического долга. Он исторически +содержит несколько разных доменных flow в крупных файлах `users/views.py`, +`users/serializers.py` и `users/helpers.py`. + +Перед активным рефакторингом модуль требует: + +- фиксации текущего поведения regression-тестами; +- разделения сценариев профиля, достижений, верификации, CV и активности; +- выноса бизнес-логики обновления профиля из serializers/helpers в service + layer; +- уточнения legacy-полей профиля и старых форматов payload. + +## Основные возможности + +- регистрация пользователя; +- подтверждение email; +- повторная отправка письма подтверждения; +- получение и обновление профиля пользователя; +- получение текущего пользователя; +- публичный список пользователей; +- список специалистов: mentors, experts, investors; +- роли пользователя: member, mentor, expert, investor; +- дополнительные данные профиля: образование, опыт, языки, ссылки; +- навыки пользователя и подтверждение навыков другими пользователями; +- достижения пользователя и файлы достижений; +- проекты пользователя, проекты лидера и лайкнутые проекты; +- подписанные проекты пользователя; +- программы пользователя и теги программ; +- события, на которые зарегистрирован пользователь; +- onboarding stage; +- принудительная верификация пользователя администратором; +- скачивание CV; +- отправка CV на email; +- отслеживание `last_login` и `last_activity`; +- новости пользователя через общий модуль `news`. + +## Архитектура + +- `users/models.py` - модель пользователя, роли, достижения, ссылки, + образование, опыт, языки и подтверждения навыков. +- `users/views.py` - HTTP endpoints и orchestration logic для профиля, + регистрации, CV, достижений, проектов, программ и событий. +- `users/serializers.py` - request/response contracts, validation и часть + бизнес-логики обновления профиля. +- `users/helpers.py` - вспомогательная логика подтверждения email, обновления + достижений/ссылок и force verify. +- `users/filters.py` - фильтры списков пользователей и специализаций. +- `users/managers.py` - queryset helpers для пользователей, достижений и + лайков проектов. +- `users/permissions.py` - permissions для достижений и expert-сценариев. +- `users/authentication.py` - JWT authentication с обновлением + `last_activity`. +- `users/signals.py` - side effects при создании/обновлении пользователя и + сбросе пароля. +- `users/tasks.py` - Celery-задача отправки CV на email. +- `users/services/` - подготовка данных для CV и пользовательской активности. +- `users/admin.py` - настройка Django admin. +- `users/tests/` - regression-тесты API, serializers/helpers, permissions, + signals и сервисов модуля. + +## Основные сущности + +- `CustomUser` - пользователь. +- `Member` - профиль участника. +- `Mentor` - профиль ментора. +- `Expert` - профиль эксперта, включая связь с партнерскими программами. +- `Investor` - профиль инвестора. +- `UserAchievement` - достижение пользователя. +- `UserAchievementFile` - файл достижения. +- `UserLink` - ссылка пользователя. +- `UserEducation` - образование пользователя. +- `UserWorkExperience` - опыт работы пользователя. +- `UserLanguages` - язык пользователя. +- `UserSkillConfirmation` - подтверждение навыка пользователя другим + пользователем. +- `LikesOnProject` - лайк проекта пользователем. + +## API + +- `POST /auth/users/` - регистрация пользователя. +- `GET /auth/users/` - список пользователей для admin. +- `GET /auth/public-users/` - публичный список пользователей. +- `GET /auth/specialists/` - список специалистов. +- `GET /auth/users//` - детали пользователя. +- `PUT /auth/users//` - полное обновление профиля. +- `PATCH /auth/users//` - частичное обновление профиля. +- `DELETE /auth/users//` - удаление пользователя. +- `GET /auth/users/current/` - профиль текущего пользователя. +- `GET /auth/users/projects/` - проекты текущего пользователя. +- `GET /auth/users/projects/leader/` - проекты, где текущий пользователь лидер. +- `GET /auth/users/liked/` - лайкнутые проекты текущего пользователя. +- `GET /auth/users//subscribed_projects/` - подписки пользователя на + проекты. +- `GET /auth/users/current/programs/` - программы текущего пользователя. +- `GET /auth/users/current/programs/tags/` - теги программ текущего + пользователя. +- `GET /auth/users/current/events/` - события текущего пользователя. +- `GET /auth/users/roles/` - дополнительные роли пользователей. +- `GET /auth/users/types/` - типы пользователей. +- `GET /auth/users/specializations/nested/` - категории специализаций с + вложенными специализациями. +- `GET /auth/users/specializations/inline/` - плоский список специализаций. +- `GET /auth/users/achievements/` - список достижений. +- `POST /auth/users/achievements/` - создание достижения текущего пользователя. +- `GET /auth/users/achievements//` - детали достижения. +- `PUT /auth/users/achievements//` - полное обновление достижения. +- `PATCH /auth/users/achievements//` - обновление достижения. +- `DELETE /auth/users/achievements//` - удаление достижения. +- `PUT /auth/users//set_onboarding_stage/` - обновление стадии онбординга. +- `POST /auth/users//force_verify/` - принудительная верификация + пользователя администратором. +- `POST /auth/users//approve_skill//` - подтверждение навыка. +- `DELETE /auth/users//approve_skill//` - удаление + подтверждения навыка. +- `GET /auth/users/download_cv/` - скачивание CV текущего пользователя. +- `GET /auth/users/send_mail_cv/` - отправка CV текущего пользователя на email. +- `POST /auth/logout/` - logout через blacklist refresh token. +- `POST /auth/resend_email/` - повторная отправка письма подтверждения. +- `GET /auth/account-confirm-email/` - подтверждение email по query token. +- `GET /auth/account-confirm-email//` - legacy route подтверждения email. +- `POST /auth/reset_password/` - сброс пароля через + `django_rest_passwordreset`. +- `GET /auth/users//news/` - новости пользователя. +- `POST /auth/users//news/` - создание новости пользователя. +- `GET /auth/users//news//` - детальная новость пользователя. +- `PATCH /auth/users//news//` - редактирование новости + пользователя. +- `DELETE /auth/users//news//` - удаление новости пользователя. +- `POST /auth/users//news//set_viewed/` - просмотр новости. +- `POST /auth/users//news//set_liked/` - лайк новости. + +## Основные сценарии + +### 1. Регистрация и подтверждение email + +Пользователь регистрируется через `POST /auth/users/`. После создания учетной +записи пользователь остается неактивным до подтверждения email. + +Система отправляет письмо с token. При переходе по ссылке подтверждения +пользователь активируется и получает access/refresh token для входа в сервис. + +### 2. Профиль пользователя + +Пользователь получает и обновляет свой профиль через `/auth/users//` или +`/auth/users/current/`. + +Профиль включает: + +- базовые поля пользователя; +- роль и данные роли; +- навыки; +- образование; +- опыт работы; +- языки; +- ссылки; +- достижения; +- проекты и программы пользователя. + +Телефон отображается только владельцу профиля, потому что используется в CV. + +### 3. Роли пользователя + +У пользователя есть основной `user_type`: + +- member; +- mentor; +- expert; +- investor. + +При создании пользователя сигнал создает соответствующий role-profile: +`Member`, `Mentor`, `Expert` или `Investor`. + +### 4. Навыки и подтверждения + +Навыки пользователя хранятся через `core.SkillToObject`. + +Другой авторизованный пользователь может подтвердить навык через +`/auth/users//approve_skill//`. Пользователь не может +подтверждать собственные навыки. + +### 5. Достижения + +Достижения пользователя доступны через `/auth/users/achievements/`. + +Создавать достижения можно только для текущего пользователя. Файлы достижения +привязываются через `UserFile` и должны принадлежать текущему пользователю. + +### 6. Проекты, программы и события + +Модуль отдает связанные с пользователем данные: + +- проекты текущего пользователя; +- проекты, где пользователь является лидером; +- лайкнутые проекты; +- подписанные проекты; +- программы пользователя; +- события, на которые пользователь зарегистрирован. + +Основная бизнес-логика этих сущностей находится в связанных модулях +`projects`, `partner_programs` и `events`. + +### 7. CV + +Пользователь может скачать CV в PDF или отправить его на свой email. + +PDF собирается из данных профиля пользователя. Для защиты от повторных +запросов используется короткий cache cooldown. + +### 8. Активность пользователя + +JWT authentication обновляет `last_activity` пользователя не чаще одного раза +за throttle window. Если cache временно недоступен, сервис пытается обновить +активность напрямую в базе и не блокирует основной запрос пользователя. + +## Ограничения и правила + +- Email пользователя уникален. +- Новый пользователь создается с `is_active = False` до подтверждения email. +- Профиль может редактировать только владелец. +- `email`, `password` и `is_active` не обновляются через обычный update + профиля. +- Пользователь с типом `member` не может менять `user_type` через текущий flow. +- Телефон скрыт от других пользователей. +- Файлы достижений должны принадлежать текущему пользователю. +- Скачивание и отправка CV ограничены cooldown. +- `last_activity` обновляется с throttle, чтобы не писать в базу на каждый + запрос. +- В модуле остаются legacy-поля `key_skills` и `speciality`; актуальные поля - + `skills` и `v2_speciality`. + +## Тесты + +Текущие тесты лежат в `users/tests/` и разделены по сценариям. + +### API и пользовательские сценарии + +- `test_auth_api.py` - регистрация, duplicate email, invalid payload, + `last_login`, `/auth/users/current/` и удаленные legacy routes. +- `test_profile_api.py` - обновление профиля владельцем, nested profile data, + skills, links, защита чужого профиля, скрытие телефона и неизменяемость + `user_type` для member. +- `test_achievements_api.py` - создание достижений, запрет создания за другого + пользователя, привязка файлов и запрет чужих файлов. +- `test_onboarding_verification_api.py` - onboarding stage, resend verify email, + force verify и role-profile signal. +- `test_skill_confirmations_api.py` - подтверждение навыков другим + пользователем, запрет self-confirmation и удаление подтверждения. +- `test_user_lists_api.py` - публичные списки пользователей, фильтры, + проекты пользователя, проекты лидера и лайкнутые проекты. +- `test_cv_api.py` - скачивание CV, отправка CV на email и cooldown. + +### Бизнес-логика и инфраструктурные сценарии + +- `test_auth_activity.py` - `last_activity` с throttle, устойчивость к ошибкам + cache и database update. +- `test_models_validators.py` - role-profile creation, ordering score, + validation языков, опыта, файлов достижений, лайков, возраста, имени, года и + телефона. +- `test_permissions.py` - permissions для достижений, expert-flow и + отключаемой authentication. +- `test_signals.py` - `dataset_migration_applied` и создание role-profile. +- `test_activity_service.py` - подготовка данных пользовательской активности, + отдельный подсчет участия в программах и проектов, поданных в программу. + +Текущий уровень покрытия модуля по `coverage` - около 82%. diff --git a/project_rates/services.py b/project_rates/services.py new file mode 100644 index 00000000..11c71819 --- /dev/null +++ b/project_rates/services.py @@ -0,0 +1,175 @@ +from django.db import transaction +from django.db.models import Count, Prefetch, Q, QuerySet + +from rest_framework.exceptions import ValidationError + +from partner_programs.models import PartnerProgram, PartnerProgramProject +from partner_programs.serializers import ProgramProjectFilterRequestSerializer +from partner_programs.utils import filter_program_projects_by_field_name +from projects.models import Project +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from project_rates.serializers import ProjectScoreCreateSerializer +from users.models import Expert +from vacancy.mapping import ProjectRatedParams, MessageTypeEnum +from vacancy.tasks import send_email + + +class MaxProjectRatesReached(Exception): + def __init__(self, max_project_rates: int): + self.max_project_rates = max_project_rates + super().__init__("max project rates reached for this program") + + +def get_rate_program(program_id: int) -> PartnerProgram: + return PartnerProgram.objects.get(pk=program_id) + + +def extract_project_rate_filters(method: str, data) -> dict: + """ + Accept filters from JSON body to mirror /partner_programs//projects/filter/: + {"filters": {"case": ["Кейс 1"]}} + """ + if method != "POST": + return {} + + body_filters = data.get("filters") if isinstance(data, dict) else {} + return body_filters if isinstance(body_filters, dict) else {} + + +def get_projects_for_rate_queryset( + *, + program: PartnerProgram, + user, + field_filters: dict, +) -> QuerySet[Project]: + filters_serializer = ProgramProjectFilterRequestSerializer( + data={"filters": field_filters} + ) + filters_serializer.is_valid(raise_exception=True) + validated_filters = filters_serializer.validated_data.get("filters", {}) + + try: + program_projects_qs = filter_program_projects_by_field_name( + program, validated_filters + ) + except ValueError as e: + raise ValidationError({"filters": str(e)}) + + project_ids = program_projects_qs.values_list("project_id", flat=True) + + scores_prefetch = Prefetch( + "scores", + queryset=ProjectScore.objects.filter( + criteria__partner_program=program + ).select_related("user"), + to_attr="_program_scores", + ) + + projects_qs = Project.objects.filter(draft=False, id__in=project_ids) + if program.is_distributed_evaluation: + projects_qs = projects_qs.filter( + expert_assignments__partner_program=program, + expert_assignments__expert__user=user, + ) + + return ( + projects_qs + .annotate( + rated_count=Count( + "scores__user", + filter=Q(scores__criteria__partner_program=program), + distinct=True, + ) + ) + .prefetch_related(scores_prefetch) + .distinct() + ) + + +def submit_project_scores(*, user, project_id: int, data) -> None: + rating_data, criteria_ids, program = _prepare_project_score_data( + user=user, + project_id=project_id, + data=data, + ) + + serializer = ProjectScoreCreateSerializer( + data=rating_data, + criteria_to_get=criteria_ids, + many=True, + ) + serializer.is_valid(raise_exception=True) + + scores_qs = ProjectScore.objects.filter( + project_id=project_id, + criteria__partner_program=program, + ) + user_has_scores = scores_qs.filter(user_id=user.id).exists() + + if program.max_project_rates: + distinct_raters = scores_qs.values("user_id").distinct().count() + if not user_has_scores and distinct_raters >= program.max_project_rates: + raise MaxProjectRatesReached(program.max_project_rates) + + with transaction.atomic(): + ProjectScore.objects.bulk_create( + [ProjectScore(**item) for item in serializer.validated_data], + update_conflicts=True, + update_fields=["value"], + unique_fields=["criteria", "user", "project"], + ) + + project = Project.objects.select_related("leader").get(id=project_id) + _send_project_rated_email(project=project, program=program) + + +def _prepare_project_score_data(*, user, project_id: int, data) -> tuple[list, list, PartnerProgram]: + rating_data = [dict(criterion) for criterion in data] + criteria_ids = [criterion["criterion_id"] for criterion in rating_data] + + criteria_qs = Criteria.objects.filter(id__in=criteria_ids).select_related( + "partner_program" + ) + partner_program_ids = ( + criteria_qs.values_list("partner_program_id", flat=True).distinct() + ) + if not criteria_qs.exists(): + raise ValueError("Criteria not found") + if partner_program_ids.count() != 1: + raise ValueError("All criteria must belong to the same program") + + program = criteria_qs.first().partner_program + Expert.objects.get(user__id=user.id, programs=program) + + for criterion in rating_data: + criterion["user"] = user.id + criterion["project"] = project_id + criterion["criteria"] = criterion.pop("criterion_id") + + if not PartnerProgramProject.objects.filter( + partner_program=program, + project_id=project_id, + ).exists(): + raise ValueError("Project is not linked to the program") + + if program.is_distributed_evaluation and not ProjectExpertAssignment.objects.filter( + partner_program=program, + project_id=project_id, + expert__user_id=user.id, + ).exists(): + raise ValueError("you are not assigned to rate this project") + + return rating_data, criteria_ids, program + + +def _send_project_rated_email(*, project: Project, program: PartnerProgram) -> None: + send_email.delay( + ProjectRatedParams( + message_type=MessageTypeEnum.PROJECT_RATED.value, + user_id=project.leader.id, + project_name=project.name, + project_id=project.id, + schema_id=2, + program_name=program.name, + ) + ) diff --git a/project_rates/tests.py b/project_rates/tests.py deleted file mode 100644 index 59e7ea12..00000000 --- a/project_rates/tests.py +++ /dev/null @@ -1,301 +0,0 @@ -from unittest.mock import patch - -from django.core.exceptions import ValidationError -from django.test import TestCase -from django.utils import timezone - -from rest_framework.test import APIClient - -from partner_programs.models import PartnerProgram, PartnerProgramProject -from projects.models import Project -from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore -from users.models import CustomUser - - -class DistributedEvaluationAPITests(TestCase): - def setUp(self): - self.client = APIClient() - now = timezone.now() - - self.expert_user = CustomUser.objects.create_user( - email="expert@example.com", - password="pass", - first_name="Expert", - last_name="User", - birthday="1990-01-01", - user_type=CustomUser.EXPERT, - is_active=True, - ) - self.other_expert_user = CustomUser.objects.create_user( - email="expert2@example.com", - password="pass", - first_name="Second", - last_name="Expert", - birthday="1991-01-01", - user_type=CustomUser.EXPERT, - is_active=True, - ) - self.leader = CustomUser.objects.create_user( - email="leader@example.com", - password="pass", - first_name="Leader", - last_name="User", - birthday="1992-01-01", - user_type=CustomUser.MEMBER, - is_active=True, - ) - - self.program = PartnerProgram.objects.create( - name="Program", - tag="program_tag", - description="Program description", - city="Moscow", - data_schema={}, - draft=False, - projects_availability="all_users", - datetime_registration_ends=now + timezone.timedelta(days=10), - datetime_started=now - timezone.timedelta(days=1), - datetime_finished=now + timezone.timedelta(days=30), - max_project_rates=2, - ) - self.expert_user.expert.programs.add(self.program) - self.other_expert_user.expert.programs.add(self.program) - - self.project_1 = Project.objects.create( - leader=self.leader, - draft=False, - is_public=False, - name="Project 1", - ) - self.project_2 = Project.objects.create( - leader=self.leader, - draft=False, - is_public=False, - name="Project 2", - ) - PartnerProgramProject.objects.create( - partner_program=self.program, - project=self.project_1, - ) - PartnerProgramProject.objects.create( - partner_program=self.program, - project=self.project_2, - ) - - self.criteria = Criteria.objects.create( - name="Impact", - type="int", - min_value=0, - max_value=10, - partner_program=self.program, - ) - - def _projects_url(self) -> str: - return f"/rate-project/{self.program.id}" - - def _rate_url(self, project_id: int) -> str: - return f"/rate-project/rate/{project_id}" - - def test_list_projects_without_distribution_returns_all_program_projects(self): - self.client.force_authenticate(self.expert_user) - - response = self.client.get(self._projects_url()) - - self.assertEqual(response.status_code, 200) - returned_ids = {item["id"] for item in response.data["results"]} - self.assertSetEqual(returned_ids, {self.project_1.id, self.project_2.id}) - - def test_list_projects_with_distribution_returns_only_assigned_projects(self): - self.program.is_distributed_evaluation = True - self.program.save(update_fields=["is_distributed_evaluation"]) - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project_1, - expert=self.expert_user.expert, - ) - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project_2, - expert=self.other_expert_user.expert, - ) - - self.client.force_authenticate(self.expert_user) - response = self.client.get(self._projects_url()) - - self.assertEqual(response.status_code, 200) - returned_ids = [item["id"] for item in response.data["results"]] - self.assertListEqual(returned_ids, [self.project_1.id]) - - @patch("project_rates.views.send_email.delay") - def test_rate_project_with_distribution_rejects_unassigned_expert(self, _mock_delay): - self.program.is_distributed_evaluation = True - self.program.save(update_fields=["is_distributed_evaluation"]) - - self.client.force_authenticate(self.expert_user) - response = self.client.post( - self._rate_url(self.project_1.id), - [{"criterion_id": self.criteria.id, "value": "8"}], - format="json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual( - response.data["error"], "you are not assigned to rate this project" - ) - self.assertFalse(ProjectScore.objects.filter(project=self.project_1).exists()) - - @patch("project_rates.views.send_email.delay") - def test_rate_project_with_distribution_accepts_assigned_expert(self, mock_delay): - self.program.is_distributed_evaluation = True - self.program.save(update_fields=["is_distributed_evaluation"]) - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project_1, - expert=self.expert_user.expert, - ) - - self.client.force_authenticate(self.expert_user) - response = self.client.post( - self._rate_url(self.project_1.id), - [{"criterion_id": self.criteria.id, "value": "8"}], - format="json", - ) - - self.assertEqual(response.status_code, 201) - self.assertTrue( - ProjectScore.objects.filter( - project=self.project_1, - user=self.expert_user, - criteria=self.criteria, - value="8", - ).exists() - ) - mock_delay.assert_called_once() - - -class ProjectExpertAssignmentModelTests(TestCase): - def setUp(self): - now = timezone.now() - self.program = PartnerProgram.objects.create( - name="Program", - tag="program_tag", - description="Program description", - city="Moscow", - data_schema={}, - draft=False, - projects_availability="all_users", - datetime_registration_ends=now + timezone.timedelta(days=10), - datetime_started=now - timezone.timedelta(days=1), - datetime_finished=now + timezone.timedelta(days=30), - max_project_rates=1, - ) - - self.leader = CustomUser.objects.create_user( - email="leader2@example.com", - password="pass", - first_name="Leader", - last_name="Two", - birthday="1993-01-01", - user_type=CustomUser.MEMBER, - is_active=True, - ) - self.project = Project.objects.create( - leader=self.leader, - draft=False, - is_public=False, - name="Project", - ) - PartnerProgramProject.objects.create( - partner_program=self.program, - project=self.project, - ) - - self.expert_1_user = CustomUser.objects.create_user( - email="model-expert-1@example.com", - password="pass", - first_name="Model", - last_name="Expert1", - birthday="1990-02-01", - user_type=CustomUser.EXPERT, - is_active=True, - ) - self.expert_2_user = CustomUser.objects.create_user( - email="model-expert-2@example.com", - password="pass", - first_name="Model", - last_name="Expert2", - birthday="1990-03-01", - user_type=CustomUser.EXPERT, - is_active=True, - ) - self.expert_1_user.expert.programs.add(self.program) - self.expert_2_user.expert.programs.add(self.program) - - def test_assignment_requires_expert_in_program(self): - self.expert_1_user.expert.programs.remove(self.program) - - with self.assertRaises(ValidationError): - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project, - expert=self.expert_1_user.expert, - ) - - def test_assignment_requires_project_link_to_program(self): - other_program = PartnerProgram.objects.create( - name="Other Program", - tag="other_program_tag", - description="Program description", - city="Moscow", - data_schema={}, - draft=False, - projects_availability="all_users", - datetime_registration_ends=timezone.now() + timezone.timedelta(days=10), - datetime_started=timezone.now() - timezone.timedelta(days=1), - datetime_finished=timezone.now() + timezone.timedelta(days=30), - ) - self.expert_1_user.expert.programs.add(other_program) - - with self.assertRaises(ValidationError): - ProjectExpertAssignment.objects.create( - partner_program=other_program, - project=self.project, - expert=self.expert_1_user.expert, - ) - - def test_assignment_respects_max_project_rates_limit(self): - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project, - expert=self.expert_1_user.expert, - ) - - with self.assertRaises(ValidationError): - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project, - expert=self.expert_2_user.expert, - ) - - def test_assignment_cannot_be_deleted_after_scoring(self): - assignment = ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project, - expert=self.expert_1_user.expert, - ) - criteria = Criteria.objects.create( - name="Impact", - type="int", - min_value=0, - max_value=10, - partner_program=self.program, - ) - ProjectScore.objects.create( - criteria=criteria, - user=self.expert_1_user, - project=self.project, - value="7", - ) - - with self.assertRaises(ValidationError): - assignment.delete() diff --git a/project_rates/tests/__init__.py b/project_rates/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project_rates/tests/helpers.py b/project_rates/tests/helpers.py new file mode 100644 index 00000000..9825baaf --- /dev/null +++ b/project_rates/tests/helpers.py @@ -0,0 +1,91 @@ +from itertools import count + +from django.utils import timezone + +from partner_programs.models import PartnerProgram, PartnerProgramProject +from projects.models import Project +from project_rates.models import Criteria +from users.models import CustomUser + +_counter = count(1) + + +def create_rate_user( + *, + prefix: str = "rate-user", + user_type: int = CustomUser.MEMBER, +): + index = next(_counter) + return CustomUser.objects.create_user( + email=f"{prefix}-{index}@example.com", + password="pass", + first_name="Rate", + last_name="User", + birthday="1990-01-01", + user_type=user_type, + is_active=True, + ) + + +def create_rate_expert(*, prefix: str = "rate-expert", program=None): + user = create_rate_user(prefix=prefix, user_type=CustomUser.EXPERT) + if program is not None: + user.expert.programs.add(program) + return user + + +def create_rate_program(**overrides): + index = next(_counter) + now = timezone.now() + defaults = { + "name": f"Rate Program {index}", + "tag": f"rate-program-{index}", + "description": "Program description", + "city": "Moscow", + "data_schema": {}, + "draft": False, + "projects_availability": "all_users", + "datetime_registration_ends": now + timezone.timedelta(days=10), + "datetime_started": now - timezone.timedelta(days=1), + "datetime_finished": now + timezone.timedelta(days=30), + "max_project_rates": 2, + } + defaults.update(overrides) + return PartnerProgram.objects.create(**defaults) + + +def create_rate_project(*, leader=None, name: str = "Rate Project", **overrides): + index = next(_counter) + defaults = { + "leader": leader or create_rate_user(prefix="rate-leader"), + "draft": False, + "is_public": False, + "name": f"{name} {index}", + } + defaults.update(overrides) + return Project.objects.create(**defaults) + + +def link_project_to_program(program, project): + return PartnerProgramProject.objects.create( + partner_program=program, + project=project, + ) + + +def create_rate_criteria( + program, + *, + name: str = "Impact", + type: str = "int", + min_value=None, + max_value=None, +): + index = next(_counter) + return Criteria.objects.create( + name=f"{name} {index}", + type=type, + min_value=min_value, + max_value=max_value, + partner_program=program, + ) diff --git a/project_rates/tests/test_distributed_evaluation.py b/project_rates/tests/test_distributed_evaluation.py new file mode 100644 index 00000000..29e28649 --- /dev/null +++ b/project_rates/tests/test_distributed_evaluation.py @@ -0,0 +1,126 @@ +from unittest.mock import patch + +from django.test import TestCase + +from rest_framework.test import APIClient + +from project_rates.models import ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import ( + create_rate_criteria, + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) + + +class DistributedEvaluationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.program = create_rate_program(max_project_rates=2) + self.expert_user = create_rate_expert(prefix="distributed-expert") + self.other_expert_user = create_rate_expert(prefix="distributed-other-expert") + self.expert_user.expert.programs.add(self.program) + self.other_expert_user.expert.programs.add(self.program) + + self.leader = create_rate_user(prefix="distributed-leader") + self.project_1 = create_rate_project( + leader=self.leader, + name="Distributed Project 1", + ) + self.project_2 = create_rate_project( + leader=self.leader, + name="Distributed Project 2", + ) + link_project_to_program(self.program, self.project_1) + link_project_to_program(self.program, self.project_2) + + self.criteria = create_rate_criteria( + self.program, + name="Impact", + min_value=0, + max_value=10, + ) + + def _projects_url(self) -> str: + return f"/rate-project/{self.program.id}" + + def _rate_url(self, project_id: int) -> str: + return f"/rate-project/rate/{project_id}" + + def test_list_projects_without_distribution_returns_all_program_projects(self): + self.client.force_authenticate(self.expert_user) + + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertSetEqual(returned_ids, {self.project_1.id, self.project_2.id}) + + def test_list_projects_with_distribution_returns_only_assigned_projects(self): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_1, + expert=self.expert_user.expert, + ) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_2, + expert=self.other_expert_user.expert, + ) + + self.client.force_authenticate(self.expert_user) + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + returned_ids = [item["id"] for item in response.data["results"]] + self.assertListEqual(returned_ids, [self.project_1.id]) + + @patch("project_rates.services.send_email.delay") + def test_rate_project_with_distribution_rejects_unassigned_expert(self, _mock_delay): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + + self.client.force_authenticate(self.expert_user) + response = self.client.post( + self._rate_url(self.project_1.id), + [{"criterion_id": self.criteria.id, "value": "8"}], + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["error"], "you are not assigned to rate this project" + ) + self.assertFalse(ProjectScore.objects.filter(project=self.project_1).exists()) + + @patch("project_rates.services.send_email.delay") + def test_rate_project_with_distribution_accepts_assigned_expert(self, mock_delay): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_1, + expert=self.expert_user.expert, + ) + + self.client.force_authenticate(self.expert_user) + response = self.client.post( + self._rate_url(self.project_1.id), + [{"criterion_id": self.criteria.id, "value": "8"}], + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertTrue( + ProjectScore.objects.filter( + project=self.project_1, + user=self.expert_user, + criteria=self.criteria, + value="8", + ).exists() + ) + mock_delay.assert_called_once() diff --git a/project_rates/tests/test_expert_assignments.py b/project_rates/tests/test_expert_assignments.py new file mode 100644 index 00000000..f054ca84 --- /dev/null +++ b/project_rates/tests/test_expert_assignments.py @@ -0,0 +1,85 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import ( + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) + + +class ProjectExpertAssignmentModelTests(TestCase): + def setUp(self): + self.program = create_rate_program(max_project_rates=1) + self.leader = create_rate_user(prefix="assignment-leader") + self.project = create_rate_project( + leader=self.leader, + name="Assignment Project", + ) + link_project_to_program(self.program, self.project) + + self.expert_1_user = create_rate_expert(prefix="assignment-expert-1") + self.expert_2_user = create_rate_expert(prefix="assignment-expert-2") + self.expert_1_user.expert.programs.add(self.program) + self.expert_2_user.expert.programs.add(self.program) + + def test_assignment_requires_expert_in_program(self): + self.expert_1_user.expert.programs.remove(self.program) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + def test_assignment_requires_project_link_to_program(self): + other_program = create_rate_program(name="Other Assignment Program") + self.expert_1_user.expert.programs.add(other_program) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=other_program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + def test_assignment_respects_max_project_rates_limit(self): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_2_user.expert, + ) + + def test_assignment_cannot_be_deleted_after_scoring(self): + assignment = ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + criteria = Criteria.objects.create( + name="Impact", + type="int", + min_value=0, + max_value=10, + partner_program=self.program, + ) + ProjectScore.objects.create( + criteria=criteria, + user=self.expert_1_user, + project=self.project, + value="7", + ) + + with self.assertRaises(ValidationError): + assignment.delete() diff --git a/project_rates/tests/test_project_list_api.py b/project_rates/tests/test_project_list_api.py new file mode 100644 index 00000000..9a90d2bf --- /dev/null +++ b/project_rates/tests/test_project_list_api.py @@ -0,0 +1,107 @@ +from django.test import TestCase + +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramField, PartnerProgramFieldValue +from project_rates.models import ProjectScore +from project_rates.tests.helpers import ( + create_rate_criteria, + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) + + +class ProjectListForRateAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.program = create_rate_program(max_project_rates=3) + self.expert = create_rate_expert(program=self.program) + self.leader = create_rate_user(prefix="rate-list-leader") + self.project_1 = create_rate_project( + leader=self.leader, + name="Filtered Project", + ) + self.project_2 = create_rate_project( + leader=self.leader, + name="Other Project", + ) + self.link_1 = link_project_to_program(self.program, self.project_1) + self.link_2 = link_project_to_program(self.program, self.project_2) + self.criteria = create_rate_criteria( + self.program, + min_value=0, + max_value=10, + ) + + def _projects_url(self) -> str: + return f"/rate-project/{self.program.id}" + + def test_post_filters_projects_by_program_field_values(self): + field = PartnerProgramField.objects.create( + partner_program=self.program, + name="track", + label="Track", + field_type="select", + show_filter=True, + options="FinTech|EdTech", + ) + PartnerProgramFieldValue.objects.create( + program_project=self.link_1, + field=field, + value_text="FinTech", + ) + PartnerProgramFieldValue.objects.create( + program_project=self.link_2, + field=field, + value_text="EdTech", + ) + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._projects_url(), + {"filters": {"track": ["FinTech"]}}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + returned_ids = [item["id"] for item in response.data["results"]] + self.assertEqual(returned_ids, [self.project_1.id]) + + def test_list_marks_current_expert_score_and_program_rate_limits(self): + ProjectScore.objects.create( + criteria=self.criteria, + user=self.expert, + project=self.project_1, + value="9", + ) + self.client.force_authenticate(self.expert) + + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + project_data = next( + item for item in response.data["results"] if item["id"] == self.project_1.id + ) + self.assertTrue(project_data["scored"]) + self.assertEqual(project_data["rated_experts"], [self.expert.id]) + self.assertEqual(project_data["rated_count"], 1) + self.assertEqual(project_data["max_rates"], 3) + self.assertEqual(project_data["criterias"][0]["value"], "9") + + def test_list_returns_criterias_when_current_expert_has_no_scores(self): + self.client.force_authenticate(self.expert) + + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + project_data = next( + item for item in response.data["results"] if item["id"] == self.project_1.id + ) + self.assertFalse(project_data["scored"]) + self.assertEqual(project_data["rated_experts"], []) + self.assertEqual(project_data["rated_count"], 0) + criteria_ids = {item["id"] for item in project_data["criterias"]} + self.assertIn(self.criteria.id, criteria_ids) diff --git a/project_rates/tests/test_rate_project_api.py b/project_rates/tests/test_rate_project_api.py new file mode 100644 index 00000000..2da94e2e --- /dev/null +++ b/project_rates/tests/test_rate_project_api.py @@ -0,0 +1,221 @@ +from unittest.mock import patch + +from django.test import TestCase + +from rest_framework.test import APIClient + +from project_rates.models import ProjectScore +from project_rates.tests.helpers import ( + create_rate_criteria, + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) + + +class RateProjectAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.program = create_rate_program(max_project_rates=2) + self.expert = create_rate_expert(program=self.program) + self.other_expert = create_rate_expert( + prefix="rate-other-expert", + program=self.program, + ) + self.leader = create_rate_user(prefix="rate-leader") + self.project = create_rate_project(leader=self.leader) + link_project_to_program(self.program, self.project) + self.criteria = create_rate_criteria( + self.program, + min_value=0, + max_value=10, + ) + + def _rate_url(self, project_id: int | None = None) -> str: + return f"/rate-project/rate/{project_id or self.project.id}" + + def _payload(self, criteria=None, value: str = "8") -> list[dict]: + return [{"criterion_id": (criteria or self.criteria).id, "value": value}] + + @patch("project_rates.services.send_email.delay") + def test_expert_can_rate_project_without_distribution(self, send_email_delay): + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(), + self._payload(value="8"), + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data, {"success": True}) + self.assertTrue( + ProjectScore.objects.filter( + criteria=self.criteria, + user=self.expert, + project=self.project, + value="8", + ).exists() + ) + send_email_delay.assert_called_once() + + @patch("project_rates.services.send_email.delay") + def test_expert_can_update_existing_score(self, send_email_delay): + ProjectScore.objects.create( + criteria=self.criteria, + user=self.expert, + project=self.project, + value="6", + ) + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(), + self._payload(value="9"), + format="json", + ) + + self.assertEqual(response.status_code, 201) + scores = ProjectScore.objects.filter( + criteria=self.criteria, + user=self.expert, + project=self.project, + ) + self.assertEqual(scores.count(), 1) + self.assertEqual(scores.get().value, "9") + send_email_delay.assert_called_once() + + @patch("project_rates.services.send_email.delay") + def test_rate_project_rejects_expert_without_program_membership( + self, + send_email_delay, + ): + outsider = create_rate_expert(prefix="rate-outsider") + self.client.force_authenticate(outsider) + + response = self.client.post( + self._rate_url(), + self._payload(value="8"), + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.data["error"], + "you have no permission to rate this program", + ) + self.assertFalse(ProjectScore.objects.filter(user=outsider).exists()) + send_email_delay.assert_not_called() + + @patch("project_rates.services.send_email.delay") + def test_rate_project_rejects_project_not_linked_to_program( + self, + send_email_delay, + ): + unlinked_project = create_rate_project( + leader=self.leader, + name="Unlinked", + ) + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(unlinked_project.id), + self._payload(value="8"), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["error"], "Project is not linked to the program") + self.assertFalse(ProjectScore.objects.filter(project=unlinked_project).exists()) + send_email_delay.assert_not_called() + + @patch("project_rates.services.send_email.delay") + def test_rate_project_rejects_criteria_from_different_programs( + self, + send_email_delay, + ): + other_program = create_rate_program(name="Other Rate Program") + other_criteria = create_rate_criteria(other_program, min_value=0, max_value=10) + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(), + [ + {"criterion_id": self.criteria.id, "value": "8"}, + {"criterion_id": other_criteria.id, "value": "7"}, + ], + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["error"], + "All criteria must belong to the same program", + ) + self.assertFalse(ProjectScore.objects.filter(project=self.project).exists()) + send_email_delay.assert_not_called() + + @patch("project_rates.services.send_email.delay") + def test_rate_project_respects_max_project_rates_for_new_expert( + self, + send_email_delay, + ): + self.program.max_project_rates = 1 + self.program.save(update_fields=["max_project_rates"]) + ProjectScore.objects.create( + criteria=self.criteria, + user=self.expert, + project=self.project, + value="8", + ) + self.client.force_authenticate(self.other_expert) + + response = self.client.post( + self._rate_url(), + self._payload(value="7"), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + { + "error": "max project rates reached for this program", + "max_project_rates": 1, + }, + ) + self.assertFalse(ProjectScore.objects.filter(user=self.other_expert).exists()) + send_email_delay.assert_not_called() + + @patch("project_rates.services.send_email.delay") + def test_rate_project_validates_numeric_limits(self, send_email_delay): + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(), + self._payload(value="11"), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("Оценка этого критерия превысила", response.data["error"]) + self.assertFalse(ProjectScore.objects.filter(project=self.project).exists()) + send_email_delay.assert_not_called() + + @patch("project_rates.services.send_email.delay") + def test_rate_project_validates_bool_value(self, send_email_delay): + bool_criteria = create_rate_criteria(self.program, type="bool") + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(), + self._payload(criteria=bool_criteria, value="yes"), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("не соответствует формату", response.data["error"]) + self.assertFalse(ProjectScore.objects.filter(criteria=bool_criteria).exists()) + send_email_delay.assert_not_called() diff --git a/project_rates/tests/test_signals.py b/project_rates/tests/test_signals.py new file mode 100644 index 00000000..f3c2b539 --- /dev/null +++ b/project_rates/tests/test_signals.py @@ -0,0 +1,17 @@ +from django.test import TestCase + +from project_rates.models import Criteria +from project_rates.tests.helpers import create_rate_program + + +class CriteriaSignalTests(TestCase): + def test_program_creation_creates_default_comment_criteria(self): + program = create_rate_program(name="Signal Program") + + self.assertTrue( + Criteria.objects.filter( + partner_program=program, + name="Комментарий", + type="str", + ).exists() + ) diff --git a/project_rates/views.py b/project_rates/views.py index 9e6f94d7..cf818a34 100644 --- a/project_rates/views.py +++ b/project_rates/views.py @@ -1,132 +1,50 @@ -from django.contrib.auth import get_user_model -from django.db import transaction -from django.db.models import Count, Prefetch, Q, QuerySet - from rest_framework import generics, status -from rest_framework.exceptions import ValidationError from rest_framework.response import Response from django_filters import rest_framework as filters -from partner_programs.models import PartnerProgram, PartnerProgramProject -from partner_programs.serializers import ProgramProjectFilterRequestSerializer -from partner_programs.utils import filter_program_projects_by_field_name -from projects.models import Project from projects.filters import ProjectFilter -from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore from project_rates.pagination import RateProjectsPagination from project_rates.serializers import ( ProjectScoreCreateSerializer, ProjectListForRateSerializer, ) +from project_rates.services import ( + MaxProjectRatesReached, + extract_project_rate_filters, + get_projects_for_rate_queryset, + get_rate_program, + submit_project_scores, +) from users.models import Expert from users.permissions import IsExpert, IsExpertPost -from vacancy.mapping import ProjectRatedParams, MessageTypeEnum -from vacancy.tasks import send_email - -User = get_user_model() class RateProject(generics.CreateAPIView): serializer_class = ProjectScoreCreateSerializer permission_classes = [IsExpertPost] - def get_needed_data(self) -> tuple[dict, list[int], PartnerProgram]: - data = self.request.data - user_id = self.request.user.id - project_id = self.kwargs.get("project_id") - - criteria_to_get = [ - criterion["criterion_id"] for criterion in data - ] # is needed for validation later - - criteria_qs = Criteria.objects.filter(id__in=criteria_to_get).select_related( - "partner_program" - ) - partner_program_ids = ( - criteria_qs.values_list("partner_program_id", flat=True).distinct() - ) - if not criteria_qs.exists(): - raise ValueError("Criteria not found") - if partner_program_ids.count() != 1: - raise ValueError("All criteria must belong to the same program") - program = criteria_qs.first().partner_program - - Expert.objects.get(user__id=user_id, programs=program) - - for criterion in data: - criterion["user"] = user_id - criterion["project"] = project_id - criterion["criteria"] = criterion.pop("criterion_id") - - if not PartnerProgramProject.objects.filter( - partner_program=program, project_id=project_id - ).exists(): - raise ValueError("Project is not linked to the program") - - if program.is_distributed_evaluation and not ProjectExpertAssignment.objects.filter( - partner_program=program, - project_id=project_id, - expert__user_id=user_id, - ).exists(): - raise ValueError("you are not assigned to rate this project") - - return data, criteria_to_get, program - def create(self, request, *args, **kwargs) -> Response: try: - data, criteria_to_get, program = self.get_needed_data() - project_id = data[0]["project"] - user_id = request.user.id - - serializer = ProjectScoreCreateSerializer( - data=data, criteria_to_get=criteria_to_get, many=True - ) - serializer.is_valid(raise_exception=True) - - scores_qs = ProjectScore.objects.filter( - project_id=project_id, criteria__partner_program=program + submit_project_scores( + user=request.user, + project_id=self.kwargs.get("project_id"), + data=request.data, ) - user_has_scores = scores_qs.filter(user_id=user_id).exists() - - if program.max_project_rates: - distinct_raters = scores_qs.values("user_id").distinct().count() - if not user_has_scores and distinct_raters >= program.max_project_rates: - return Response( - { - "error": "max project rates reached for this program", - "max_project_rates": program.max_project_rates, - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - with transaction.atomic(): - ProjectScore.objects.bulk_create( - [ProjectScore(**item) for item in serializer.validated_data], - update_conflicts=True, - update_fields=["value"], - unique_fields=["criteria", "user", "project"], - ) - - project = Project.objects.select_related("leader").get(id=project_id) - - send_email.delay( - ProjectRatedParams( - message_type=MessageTypeEnum.PROJECT_RATED.value, - user_id=project.leader.id, - project_name=project.name, - project_id=project.id, - schema_id=2, - program_name=program.name, - ) - ) - return Response({"success": True}, status=status.HTTP_201_CREATED) except Expert.DoesNotExist: return Response( {"error": "you have no permission to rate this program"}, status=status.HTTP_403_FORBIDDEN, ) + except MaxProjectRatesReached as e: + return Response( + { + "error": str(e), + "max_project_rates": e.max_project_rates, + }, + status=status.HTTP_400_BAD_REQUEST, + ) except ValueError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: @@ -144,64 +62,19 @@ def post(self, request, *args, **kwargs): """Allow POST with filters in JSON body.""" return self.list(request, *args, **kwargs) - def _get_program(self) -> PartnerProgram: - return PartnerProgram.objects.get(pk=self.kwargs.get("program_id")) - - def _get_filters(self) -> dict: - """ - Accept filters from JSON body to mirror /partner_programs//projects/filter/: - {"filters": {"case": ["Кейс 1"]}} - """ - if self.request.method != "POST": - return {} - data = getattr(self.request, "data", None) - body_filters = data.get("filters") if isinstance(data, dict) else {} - return body_filters if isinstance(body_filters, dict) else {} - - def get_queryset(self) -> QuerySet[Project]: - program = self._get_program() - - filters_serializer = ProgramProjectFilterRequestSerializer( - data={"filters": self._get_filters()} - ) - filters_serializer.is_valid(raise_exception=True) - field_filters = filters_serializer.validated_data.get("filters", {}) - - try: - program_projects_qs = filter_program_projects_by_field_name( - program, field_filters - ) - except ValueError as e: - raise ValidationError({"filters": str(e)}) - - project_ids = program_projects_qs.values_list("project_id", flat=True) - - scores_prefetch = Prefetch( - "scores", - queryset=ProjectScore.objects.filter( - criteria__partner_program=program - ).select_related("user"), - to_attr="_program_scores", - ) - - projects_qs = Project.objects.filter(draft=False, id__in=project_ids) - if program.is_distributed_evaluation: - projects_qs = projects_qs.filter( - expert_assignments__partner_program=program, - expert_assignments__expert__user=self.request.user, - ) - - return ( - projects_qs - .annotate( - rated_count=Count( - "scores__user", - filter=Q(scores__criteria__partner_program=program), - distinct=True, - ) - ) - .prefetch_related(scores_prefetch) - .distinct() + def _get_program(self): + if not hasattr(self, "_program"): + self._program = get_rate_program(self.kwargs.get("program_id")) + return self._program + + def get_queryset(self): + return get_projects_for_rate_queryset( + program=self._get_program(), + user=self.request.user, + field_filters=extract_project_rate_filters( + self.request.method, + getattr(self.request, "data", None), + ), ) def get_serializer_context(self): diff --git a/users/services/users_activity.py b/users/services/users_activity.py index 1f3a7ffa..041f252f 100644 --- a/users/services/users_activity.py +++ b/users/services/users_activity.py @@ -92,6 +92,13 @@ def __prepare_user_data(self, user: CustomUser) -> dict[str, Any]: def __get_user_queryset(self) -> QuerySet[CustomUser]: user_content_type = ContentType.objects.get_for_model(CustomUser) + program_profiles_count_subquery = ( + PartnerProgramUserProfile.objects + .filter(user_id=OuterRef("id")) + .values("user_id") + .annotate(total=Count("id")) + .values("total") + ) projects_in_program_subquery = ( PartnerProgramUserProfile.objects .filter(user_id=OuterRef("id")) @@ -114,13 +121,6 @@ def __get_user_queryset(self) -> QuerySet[CustomUser]: .values("total_likes") ) - projects_in_program_subquery = ( - CustomUser.objects - .filter(partner_program_profiles__user_id=OuterRef("id")) - .annotate(total_proj=Count("id")) - .values("total_proj") - ) - users: QuerySet[CustomUser] = ( CustomUser.objects .prefetch_related( @@ -140,7 +140,7 @@ def __get_user_queryset(self) -> QuerySet[CustomUser]: output_field=IntegerField(), ), program_profiles_count=Coalesce( - Subquery(projects_in_program_subquery, output_field=IntegerField()), + Subquery(program_profiles_count_subquery, output_field=IntegerField()), Value(0), output_field=IntegerField(), ), diff --git a/users/tests.py b/users/tests.py deleted file mode 100644 index e2cafeb4..00000000 --- a/users/tests.py +++ /dev/null @@ -1,113 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIRequestFactory, force_authenticate -from tests.constants import USER_CREATE_DATA - -from projects.models import Collaborator, Project -from users.models import CustomUser -from users.serializers import UserDetailSerializer -from users.views import CurrentUser, UserLeaderProjectsList, UserList, UserDetail - - -class UserTestCase(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.user_list_view = UserList.as_view() - self.user_detail_view = UserDetail.as_view() - self.user_leader_projects_view = UserLeaderProjectsList.as_view() - self.current_user_view = CurrentUser.as_view() - - def test_user_creation(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data["email"], "only_for_test@test.test") - self.assertEqual(response.data["is_active"], False) - - def test_user_creation_with_wrong_data(self): - request = self.factory.post( - "auth/users/", - { - "email": "qwe", - "password": "qwe", - "first_name": "qwe", - "last_name": "qwe", - }, - ) - response = self.user_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_user_creation_with_existing_email(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - self.assertEqual(response.status_code, 201) - response = self.user_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_user_update(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - - request = self.factory.get(f"auth/users/{user.pk}/") - response = self.user_detail_view(request, pk=user.pk) - self.assertEqual(response.status_code, 401) # Unauthorized - - force_authenticate(request, user=user) - response = self.user_detail_view(request, pk=user.pk) - self.assertEqual(response.status_code, 200) - - request = self.factory.patch(f"auth/users/{user.pk}/", {"first_name": "Сергей"}) - force_authenticate(request, user=user) - response = self.user_detail_view(request, pk=user.pk) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["first_name"], "Сергей") - - def test_user_leader_projects_list(self): - leader = self._user_create("leader@example.com") - collaborator = self._user_create("collaborator@example.com") - - leader_project = Project.objects.create(name="Leader project", leader=leader) - second_leader_project = Project.objects.create( - name="Leader project 2", leader=leader, draft=False - ) - other_project = Project.objects.create(name="Other project", leader=collaborator) - Collaborator.objects.create(user=leader, project=other_project, role="Member") - - request = self.factory.get("users/projects/leader/") - force_authenticate(request, user=leader) - response = self.user_leader_projects_view(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["count"], 2) - returned_ids = {item["id"] for item in response.data["results"]} - self.assertSetEqual( - returned_ids, {leader_project.id, second_leader_project.id} - ) - - def test_current_user_returns_authenticated_user_profile(self): - user = self._user_create("current@example.com") - - request = self.factory.get("auth/users/current/") - force_authenticate(request, user=user) - response = self.current_user_view(request) - expected_data = UserDetailSerializer(user, context={"request": request}).data - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, expected_data) - - def test_removed_legacy_routes_return_404(self): - self.assertEqual(self.client.get("/auth/users/clone-data").status_code, 404) - self.assertEqual(self.client.get("/auth/subscription/").status_code, 404) - self.assertEqual(self.client.post("/auth/subscription/buy/").status_code, 404) - - def _user_create(self, email): - tmp_create_data = USER_CREATE_DATA.copy() - tmp_create_data["email"] = email - request = self.factory.post("auth/users/", tmp_create_data) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - user.is_active = True - user.save() - return user diff --git a/users/tests/__init__.py b/users/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/users/tests/helpers.py b/users/tests/helpers.py new file mode 100644 index 00000000..d6eeef2d --- /dev/null +++ b/users/tests/helpers.py @@ -0,0 +1,118 @@ +from datetime import date, timedelta + +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone + +from core.models import Skill, SkillCategory, SkillToObject, Specialization, SpecializationCategory +from files.models import UserFile +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from projects.models import Project +from users.models import CustomUser + + +def build_user( + email: str = "user@example.com", + *, + password: str = "very_strong_password", + first_name: str = "Иван", + last_name: str = "Иванов", + user_type: int = CustomUser.MEMBER, + is_active: bool = True, + **extra_fields, +) -> CustomUser: + defaults = { + "email": email, + "password": password, + "first_name": first_name, + "last_name": last_name, + "birthday": date(2000, 1, 1), + "user_type": user_type, + "is_active": is_active, + } + defaults.update(extra_fields) + return CustomUser.objects.create_user(**defaults) + + +def build_superuser(email: str = "admin@example.com") -> CustomUser: + return CustomUser.objects.create_superuser( + email=email, + password="very_strong_password", + first_name="Админ", + last_name="Админов", + ) + + +def build_skill(name: str = "Python") -> Skill: + category, _ = SkillCategory.objects.get_or_create(name="Backend") + return Skill.objects.create(name=name, category=category) + + +def attach_skill(user: CustomUser, skill: Skill) -> SkillToObject: + return SkillToObject.objects.create( + skill=skill, + content_type=ContentType.objects.get_for_model(CustomUser), + object_id=user.id, + ) + + +def build_specialization(name: str = "Backend developer") -> Specialization: + category, _ = SpecializationCategory.objects.get_or_create(name="IT") + return Specialization.objects.create(name=name, category=category) + + +def build_user_file( + user: CustomUser, + *, + link: str = "https://cdn.example.com/file.pdf", + extension: str = "pdf", + size: int = 1024, +) -> UserFile: + return UserFile.objects.create( + user=user, + link=link, + name="file", + extension=extension, + mime_type="application/pdf", + size=size, + ) + + +def build_project( + leader: CustomUser, + *, + name: str = "Проект", + draft: bool = False, +) -> Project: + return Project.objects.create(name=name, leader=leader, draft=draft) + + +def build_partner_program( + *, + name: str = "Программа", + tag: str = "program", + draft: bool = False, +) -> PartnerProgram: + now = timezone.now() + return PartnerProgram.objects.create( + name=name, + tag=tag, + city="Екатеринбург", + draft=draft, + datetime_started=now - timedelta(days=1), + datetime_registration_ends=now + timedelta(days=10), + datetime_finished=now + timedelta(days=20), + ) + + +def add_user_to_program( + user: CustomUser, + program: PartnerProgram, + *, + project: Project | None = None, +) -> PartnerProgramUserProfile: + return PartnerProgramUserProfile.objects.create( + user=user, + project=project, + partner_program=program, + partner_program_data={}, + ) diff --git a/users/tests/test_achievements_api.py b/users/tests/test_achievements_api.py new file mode 100644 index 00000000..52e5bb18 --- /dev/null +++ b/users/tests/test_achievements_api.py @@ -0,0 +1,114 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from users.models import UserAchievement + +from .helpers import build_user, build_user_file + + +class UserAchievementAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="achievements@example.com") + self.client.force_authenticate(user=self.user) + + def test_user_can_create_achievement_with_owned_file(self): + user_file = build_user_file(self.user) + + response = self.client.post( + "/auth/users/achievements/", + { + "title": "Победа", + "status": "Первое место", + "year": 2024, + "file_links": [user_file.link], + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + achievement = UserAchievement.objects.get(user=self.user) + self.assertEqual(achievement.files.get(), user_file) + + def test_user_cannot_create_achievement_for_another_user(self): + other_user = build_user(email="achievement-owner@example.com") + + response = self.client.post( + "/auth/users/achievements/", + { + "user": other_user.id, + "title": "Победа", + "status": "Первое место", + "year": 2024, + }, + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse(UserAchievement.objects.filter(user=other_user).exists()) + + def test_user_cannot_attach_foreign_file_to_achievement(self): + other_user = build_user(email="file-owner@example.com") + foreign_file = build_user_file( + other_user, + link="https://cdn.example.com/foreign.pdf", + ) + + response = self.client.post( + "/auth/users/achievements/", + { + "title": "Победа", + "status": "Первое место", + "year": 2024, + "file_links": [foreign_file.link], + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(UserAchievement.objects.filter(user=self.user).exists()) + + def test_profile_update_replaces_achievements_and_validates_owned_files(self): + old_achievement = UserAchievement.objects.create( + user=self.user, + title="Старое достижение", + status="Участник", + year=2023, + ) + user_file = build_user_file(self.user) + + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "achievements": [ + { + "title": "Новое достижение", + "status": "Победитель", + "year": 2024, + "file_links": [user_file.link], + } + ] + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(UserAchievement.objects.filter(id=old_achievement.id).exists()) + achievement = UserAchievement.objects.get(user=self.user) + self.assertEqual(achievement.title, "Новое достижение") + self.assertEqual(achievement.files.get(), user_file) + + def test_profile_update_rejects_duplicate_achievements(self): + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "achievements": [ + {"title": "Победа", "status": "Первое место", "year": 2024}, + {"title": "Победа", "status": "Первое место", "year": 2024}, + ] + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(UserAchievement.objects.filter(user=self.user).exists()) diff --git a/users/tests/test_activity_service.py b/users/tests/test_activity_service.py new file mode 100644 index 00000000..ab61392b --- /dev/null +++ b/users/tests/test_activity_service.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from users.services.users_activity import UserActivityDataPreparer + +from .helpers import add_user_to_program, build_partner_program, build_project, build_user + + +class UserActivityDataPreparerTests(TestCase): + def test_activity_data_counts_program_membership_and_program_project_separately(self): + user = build_user(email="activity-report@example.com") + program = build_partner_program() + add_user_to_program(user, program) + + data = UserActivityDataPreparer().get_users_prepared_data() + user_row = next(row for row in data if row["ID пользователя"] == user.id) + + self.assertEqual(user_row["Участие в программах кол-во"], 1) + self.assertEqual(user_row["Кол-во проектов в программе"], 0) + + def test_activity_data_counts_project_submitted_to_program(self): + user = build_user(email="activity-project@example.com") + project = build_project(user) + program = build_partner_program(tag="project-program") + add_user_to_program(user, program, project=project) + + data = UserActivityDataPreparer().get_users_prepared_data() + user_row = next(row for row in data if row["ID пользователя"] == user.id) + + self.assertEqual(user_row["Участие в программах кол-во"], 1) + self.assertEqual(user_row["Кол-во проектов в программе"], 1) diff --git a/users/tests_auth_activity.py b/users/tests/test_auth_activity.py similarity index 72% rename from users/tests_auth_activity.py rename to users/tests/test_auth_activity.py index 57a398e7..2d56fe7b 100644 --- a/users/tests_auth_activity.py +++ b/users/tests/test_auth_activity.py @@ -3,25 +3,18 @@ from django.core.cache import cache from django.urls import reverse -from django.utils import timezone from rest_framework.test import APIClient, APITestCase from users.authentication import get_last_activity_cache_key -from users.models import CustomUser + +from .helpers import build_user class JwtActivityTrackingTests(APITestCase): def setUp(self): self.email = "activity_test@example.com" self.password = "very_strong_password" - self.user = CustomUser.objects.create_user( - email=self.email, - password=self.password, - first_name="Иван", - last_name="Иванов", - birthday="2000-01-01", - is_active=True, - ) + self.user = build_user(email=self.email, password=self.password) def _obtain_access_token(self) -> str: response = self.client.post( @@ -32,24 +25,9 @@ def _obtain_access_token(self) -> str: self.assertEqual(response.status_code, 200) return response.data["access"] - def test_token_obtain_pair_updates_last_login(self): - old_login = timezone.now() - timedelta(days=1) - CustomUser.objects.filter(id=self.user.id).update(last_login=old_login) - - response = self.client.post( - reverse("token_obtain_pair"), - {"email": self.email, "password": self.password}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - self.user.refresh_from_db() - self.assertIsNotNone(self.user.last_login) - self.assertGreater(self.user.last_login, old_login) - def test_last_activity_updates_with_throttle(self): cache.delete(get_last_activity_cache_key(self.user.id)) - CustomUser.objects.filter(id=self.user.id).update(last_activity=None) + self.user.__class__.objects.filter(id=self.user.id).update(last_activity=None) access = self._obtain_access_token() api_client = APIClient() @@ -66,9 +44,10 @@ def test_last_activity_updates_with_throttle(self): self.user.refresh_from_db() self.assertEqual(self.user.last_activity, first_activity) - # Simulate throttle window end for deterministic testing. old_activity = first_activity - timedelta(hours=1) - CustomUser.objects.filter(id=self.user.id).update(last_activity=old_activity) + self.user.__class__.objects.filter(id=self.user.id).update( + last_activity=old_activity + ) cache.delete(get_last_activity_cache_key(self.user.id)) third_response = api_client.get("/auth/specialists/") @@ -78,13 +57,14 @@ def test_last_activity_updates_with_throttle(self): @patch("users.authentication.cache.add", side_effect=Exception("cache is down")) def test_last_activity_cache_failure_does_not_break_auth(self, _cache_add_mock): - CustomUser.objects.filter(id=self.user.id).update(last_activity=None) + self.user.__class__.objects.filter(id=self.user.id).update(last_activity=None) access = self._obtain_access_token() api_client = APIClient() api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}") response = api_client.get("/auth/specialists/") + self.assertEqual(response.status_code, 200) self.user.refresh_from_db() self.assertIsNotNone(self.user.last_activity) @@ -102,6 +82,7 @@ def test_last_activity_db_failure_does_not_break_auth(self, get_user_model_mock) api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}") response = api_client.get("/auth/specialists/") + self.assertEqual(response.status_code, 200) fake_model.objects.filter.assert_called_once_with(id=self.user.id) fake_qs.update.assert_called_once() diff --git a/users/tests/test_auth_api.py b/users/tests/test_auth_api.py new file mode 100644 index 00000000..4cff999b --- /dev/null +++ b/users/tests/test_auth_api.py @@ -0,0 +1,85 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from tests.constants import USER_CREATE_DATA +from users.models import CustomUser +from users.serializers import UserDetailSerializer + +from .helpers import build_user + + +class UserRegistrationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("users.views.verify_email") + def test_user_registration_creates_inactive_user_and_sends_email(self, verify_email_mock): + response = self.client.post("/auth/users/", USER_CREATE_DATA, format="json") + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["email"], USER_CREATE_DATA["email"]) + self.assertEqual(response.data["is_active"], False) + user = CustomUser.objects.get(email=USER_CREATE_DATA["email"]) + verify_email_mock.assert_called_once() + self.assertEqual(verify_email_mock.call_args.args[0], user) + + @patch("users.views.verify_email") + def test_user_registration_rejects_duplicate_email(self, _verify_email_mock): + first_response = self.client.post("/auth/users/", USER_CREATE_DATA, format="json") + second_response = self.client.post("/auth/users/", USER_CREATE_DATA, format="json") + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(second_response.status_code, 400) + + def test_user_registration_rejects_invalid_payload(self): + response = self.client.post( + "/auth/users/", + { + "email": "wrong-email", + "password": "qwe", + "first_name": "И", + "last_name": "Иванов", + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + def test_token_obtain_pair_updates_last_login(self): + user = build_user(email="login@example.com") + + response = self.client.post( + reverse("token_obtain_pair"), + {"email": user.email, "password": "very_strong_password"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + user.refresh_from_db() + self.assertIsNotNone(user.last_login) + + +class CurrentUserAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_current_user_returns_authenticated_user_profile(self): + user = build_user(email="current@example.com") + self.client.force_authenticate(user=user) + + response = self.client.get("/auth/users/current/") + expected_data = UserDetailSerializer( + user, + context={"request": response.wsgi_request}, + ).data + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, expected_data) + + def test_removed_legacy_routes_return_404(self): + self.assertEqual(self.client.get("/auth/users/clone-data").status_code, 404) + self.assertEqual(self.client.get("/auth/subscription/").status_code, 404) + self.assertEqual(self.client.post("/auth/subscription/buy/").status_code, 404) diff --git a/users/tests/test_cv_api.py b/users/tests/test_cv_api.py new file mode 100644 index 00000000..a67033b7 --- /dev/null +++ b/users/tests/test_cv_api.py @@ -0,0 +1,78 @@ +from unittest.mock import MagicMock, patch + +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient + +from .helpers import build_user + + +class UserCVAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = build_user(email="cv@example.com") + self.client.force_authenticate(user=self.user) + + @patch("users.views.HTML") + @patch("users.views.render_to_string", return_value="cv") + @patch("users.views.UserCVDataPreparerV2") + def test_user_can_download_cv_pdf( + self, + data_preparer_mock, + _render_to_string_mock, + html_mock, + ): + preparer = MagicMock() + preparer.TEMPLATE_PATH = "template.html" + preparer.get_prepared_data.return_value = {"base_user_info": self.user} + data_preparer_mock.return_value = preparer + html_mock.return_value.write_pdf.return_value = b"pdf" + + response = self.client.get("/auth/users/download_cv/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/pdf") + self.assertEqual(response.content, b"pdf") + + @patch("users.views.HTML") + @patch("users.views.render_to_string", return_value="cv") + @patch("users.views.UserCVDataPreparerV2") + def test_cv_download_has_cooldown( + self, + data_preparer_mock, + _render_to_string_mock, + html_mock, + ): + preparer = MagicMock() + preparer.TEMPLATE_PATH = "template.html" + preparer.get_prepared_data.return_value = {"base_user_info": self.user} + data_preparer_mock.return_value = preparer + html_mock.return_value.write_pdf.return_value = b"pdf" + + first_response = self.client.get("/auth/users/download_cv/") + second_response = self.client.get("/auth/users/download_cv/") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 400) + self.assertIn("seconds_after_retry", second_response.data) + + @patch("users.views.send_mail_cv.delay") + def test_user_can_schedule_cv_email(self, delay_mock): + response = self.client.get("/auth/users/send_mail_cv/") + + self.assertEqual(response.status_code, 200) + delay_mock.assert_called_once_with( + user_id=self.user.id, + user_email=self.user.email, + filename=f"{self.user.first_name}_{self.user.last_name}", + ) + + @patch("users.views.send_mail_cv.delay") + def test_cv_email_has_cooldown(self, delay_mock): + first_response = self.client.get("/auth/users/send_mail_cv/") + second_response = self.client.get("/auth/users/send_mail_cv/") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 400) + self.assertEqual(delay_mock.call_count, 1) diff --git a/users/tests/test_models_validators.py b/users/tests/test_models_validators.py new file mode 100644 index 00000000..ef00d232 --- /dev/null +++ b/users/tests/test_models_validators.py @@ -0,0 +1,128 @@ +from datetime import date + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.test import TestCase +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from users import constants +from users.models import ( + LikesOnProject, + UserAchievement, + UserAchievementFile, + UserLanguages, + UserWorkExperience, +) +from users.validators import ( + user_birthday_validator, + user_experience_years_range_validator, + user_name_validator, + user_phone_number_validation, +) + +from .helpers import build_project, build_user, build_user_file + + +class UserModelValidationTests(TestCase): + def test_role_profile_is_created_for_new_user(self): + user = build_user(email="role@example.com") + + self.assertTrue(hasattr(user, "member")) + + def test_ordering_score_is_recalculated_after_profile_change(self): + user = build_user(email="ordering@example.com") + user.about_me = "О себе" + user.city = "Москва" + user.save() + + user.refresh_from_db() + self.assertGreater(user.ordering_score, 0) + + def test_user_language_limit_is_enforced(self): + user = build_user(email="languages@example.com") + for language in list(constants.UserLanguagesEnum)[:4]: + UserLanguages.objects.create( + user=user, + language=language.value, + language_level=constants.UserLanguagesLevels.B1.value, + ) + + with self.assertRaises(DjangoValidationError): + UserLanguages.objects.create( + user=user, + language=constants.UserLanguagesEnum.FRENCH.value, + language_level=constants.UserLanguagesLevels.B1.value, + ) + + def test_work_experience_rejects_completion_before_entry(self): + user = build_user(email="experience@example.com") + + with self.assertRaises(DjangoValidationError): + UserWorkExperience.objects.create( + user=user, + organization_name="Компания", + entry_year=2024, + completion_year=2020, + ) + + def test_achievement_file_requires_same_owner(self): + owner = build_user(email="achievement-file-owner@example.com") + other_user = build_user(email="foreign-file-owner@example.com") + achievement = UserAchievement.objects.create( + user=owner, + title="Победа", + status="Первое место", + year=2024, + ) + foreign_file = build_user_file( + other_user, + link="https://cdn.example.com/foreign-achievement.pdf", + ) + + link = UserAchievementFile(achievement=achievement, file=foreign_file) + + with self.assertRaises(DjangoValidationError): + link.clean() + + def test_project_like_manager_toggles_existing_like(self): + user = build_user(email="likes@example.com") + project = build_project(user) + + first_like = LikesOnProject.objects.toggle_like(user, project) + second_like = LikesOnProject.objects.toggle_like(user, project) + + self.assertTrue(first_like.is_liked) + self.assertEqual(first_like.id, second_like.id) + second_like.refresh_from_db() + self.assertFalse(second_like.is_liked) + + +class UserValidatorsTests(TestCase): + def test_birthday_validator_accepts_adult_user(self): + birthday = timezone.now().date().replace(year=timezone.now().date().year - 20) + + self.assertTrue(user_birthday_validator(birthday)) + + def test_birthday_validator_rejects_too_young_user(self): + birthday = timezone.now().date().replace(year=timezone.now().date().year - 10) + + with self.assertRaises(ValidationError): + user_birthday_validator(birthday) + + def test_birthday_validator_rejects_user_older_than_100(self): + birthday = date(timezone.now().date().year - 101, 1, 1) + + with self.assertRaises(ValidationError): + user_birthday_validator(birthday) + + def test_name_validator_rejects_non_cyrillic_value(self): + with self.assertRaises(DjangoValidationError): + user_name_validator("John", field_name="Имя") + + def test_experience_year_validator_rejects_out_of_range_year(self): + with self.assertRaises(DjangoValidationError): + user_experience_years_range_validator(1900) + + def test_phone_validator_rejects_invalid_phone_number(self): + with self.assertRaises(DjangoValidationError): + user_phone_number_validation("wrong-phone") diff --git a/users/tests/test_onboarding_verification_api.py b/users/tests/test_onboarding_verification_api.py new file mode 100644 index 00000000..e7ad2678 --- /dev/null +++ b/users/tests/test_onboarding_verification_api.py @@ -0,0 +1,111 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from users.models import CustomUser + +from .helpers import build_superuser, build_user + + +class UserOnboardingAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="onboarding@example.com") + self.client.force_authenticate(user=self.user) + + def test_user_can_update_own_onboarding_stage(self): + response = self.client.put( + f"/auth/users/{self.user.id}/set_onboarding_stage/", + {"onboarding_stage": 1}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.onboarding_stage, 1) + + def test_user_cannot_update_another_user_onboarding_stage(self): + other_user = build_user(email="other-onboarding@example.com") + + response = self.client.put( + f"/auth/users/{other_user.id}/set_onboarding_stage/", + {"onboarding_stage": 1}, + format="json", + ) + + self.assertEqual(response.status_code, 403) + other_user.refresh_from_db() + self.assertNotEqual(other_user.onboarding_stage, 1) + + def test_user_cannot_set_invalid_onboarding_stage(self): + response = self.client.put( + f"/auth/users/{self.user.id}/set_onboarding_stage/", + {"onboarding_stage": 4}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + +class UserVerificationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("users.views.verify_email") + def test_resend_verify_email_for_inactive_user(self, verify_email_mock): + user = build_user(email="inactive@example.com", is_active=False) + + response = self.client.post( + "/auth/resend_email/", + {"email": user.email}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + verify_email_mock.assert_called_once() + + @patch("users.views.verify_email") + def test_resend_verify_email_does_not_send_for_active_user(self, verify_email_mock): + user = build_user(email="active@example.com", is_active=True) + + response = self.client.post( + "/auth/resend_email/", + {"email": user.email}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + verify_email_mock.assert_not_called() + + def test_admin_can_force_verify_user(self): + admin = build_superuser() + user = build_user(email="force@example.com", is_active=False) + self.client.force_authenticate(user=admin) + + response = self.client.post(f"/auth/users/{user.id}/force_verify/") + + self.assertEqual(response.status_code, 200) + user.refresh_from_db() + self.assertTrue(user.is_active) + self.assertEqual(user.verification_date, timezone.now().date()) + + def test_regular_user_cannot_force_verify_user(self): + actor = build_user(email="regular@example.com") + user = build_user(email="target@example.com", is_active=False) + self.client.force_authenticate(user=actor) + + response = self.client.post(f"/auth/users/{user.id}/force_verify/") + + self.assertEqual(response.status_code, 403) + user.refresh_from_db() + self.assertFalse(user.is_active) + + def test_user_type_signal_creates_role_profile(self): + expert = build_user( + email="expert-profile@example.com", + user_type=CustomUser.EXPERT, + ) + + self.assertTrue(hasattr(expert, "expert")) diff --git a/users/tests/test_permissions.py b/users/tests/test_permissions.py new file mode 100644 index 00000000..19d15ae8 --- /dev/null +++ b/users/tests/test_permissions.py @@ -0,0 +1,90 @@ +from types import SimpleNamespace + +from django.test import TestCase +from rest_framework.exceptions import PermissionDenied + +from users.models import CustomUser, UserAchievement +from users.permissions import ( + CustomIsAuthenticated, + IsAchievementOwnerOrReadOnly, + IsExpert, + IsExpertPost, +) + +from .helpers import build_partner_program, build_user + + +class UserPermissionsTests(TestCase): + def test_achievement_owner_can_update_achievement(self): + user = build_user(email="achievement-owner@example.com") + achievement = UserAchievement.objects.create( + user=user, + title="Победа", + status="Первое место", + year=2024, + ) + request = SimpleNamespace(method="PATCH", user=user) + + self.assertTrue( + IsAchievementOwnerOrReadOnly().has_object_permission( + request, + None, + achievement, + ) + ) + + def test_non_owner_cannot_update_achievement(self): + owner = build_user(email="achievement-owner@example.com") + viewer = build_user(email="achievement-viewer@example.com") + achievement = UserAchievement.objects.create( + user=owner, + title="Победа", + status="Первое место", + year=2024, + ) + request = SimpleNamespace(method="PATCH", user=viewer) + + self.assertFalse( + IsAchievementOwnerOrReadOnly().has_object_permission( + request, + None, + achievement, + ) + ) + + def test_expert_permission_allows_program_expert(self): + user = build_user( + email="expert@example.com", + user_type=CustomUser.EXPERT, + ) + program = build_partner_program() + user.expert.programs.add(program) + request = SimpleNamespace(user=user) + view = SimpleNamespace(kwargs={"program_id": program.id}) + + self.assertTrue(IsExpert().has_permission(request, view)) + + def test_expert_permission_rejects_outsider(self): + user = build_user(email="member@example.com") + program = build_partner_program() + request = SimpleNamespace(user=user) + view = SimpleNamespace(kwargs={"program_id": program.id}) + + with self.assertRaises(PermissionDenied): + IsExpert().has_permission(request, view) + + def test_expert_post_permission_allows_expert_user_type(self): + user = build_user( + email="expert-post@example.com", + user_type=CustomUser.EXPERT, + ) + request = SimpleNamespace(user=user) + + self.assertTrue(IsExpertPost().has_permission(request, None)) + + def test_custom_is_authenticated_can_be_disabled_by_view_flag(self): + anonymous_user = SimpleNamespace(is_authenticated=False) + request = SimpleNamespace(user=anonymous_user) + view = SimpleNamespace(authentication_off=True) + + self.assertTrue(CustomIsAuthenticated().has_permission(request, view)) diff --git a/users/tests/test_profile_api.py b/users/tests/test_profile_api.py new file mode 100644 index 00000000..2ca4d0d7 --- /dev/null +++ b/users/tests/test_profile_api.py @@ -0,0 +1,125 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from core.models import SkillToObject +from users.constants import UserLanguagesEnum, UserLanguagesLevels +from users.models import CustomUser, UserEducation, UserLanguages, UserLink, UserWorkExperience + +from .helpers import build_skill, build_specialization, build_user + + +class UserProfileUpdateAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="profile@example.com") + self.client.force_authenticate(user=self.user) + + def test_owner_can_update_profile_related_data(self): + skill = build_skill("Django") + specialization = build_specialization("Backend") + + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "first_name": "Сергей", + "city": "Москва", + "phone_number": "+79991234567", + "v2_speciality_id": specialization.id, + "skills_ids": [skill.id], + "education": [ + { + "organization_name": "Университет", + "description": "Информатика", + "entry_year": 2018, + "completion_year": 2022, + "education_level": "Высшее образование – бакалавриат, специалитет", + "education_status": "Выпускник", + } + ], + "work_experience": [ + { + "organization_name": "Компания", + "description": "Backend", + "entry_year": 2022, + "completion_year": 2024, + "job_position": "Разработчик", + } + ], + "user_languages": [ + { + "language": UserLanguagesEnum.ENGLISH.value, + "language_level": UserLanguagesLevels.B2.value, + } + ], + "links": ["https://example.com/profile"], + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, "Сергей") + self.assertEqual(self.user.v2_speciality_id, specialization.id) + self.assertEqual(UserEducation.objects.filter(user=self.user).count(), 1) + self.assertEqual(UserWorkExperience.objects.filter(user=self.user).count(), 1) + self.assertEqual(UserLanguages.objects.filter(user=self.user).count(), 1) + self.assertEqual(UserLink.objects.filter(user=self.user).count(), 1) + self.assertTrue( + SkillToObject.objects.filter(object_id=self.user.id, skill=skill).exists() + ) + + def test_owner_cannot_add_duplicate_languages(self): + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "user_languages": [ + { + "language": UserLanguagesEnum.ENGLISH.value, + "language_level": UserLanguagesLevels.B1.value, + }, + { + "language": UserLanguagesEnum.ENGLISH.value, + "language_level": UserLanguagesLevels.B2.value, + }, + ] + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(UserLanguages.objects.filter(user=self.user).exists()) + + def test_user_cannot_update_another_profile(self): + other_user = build_user(email="other@example.com") + + response = self.client.patch( + f"/auth/users/{other_user.id}/", + {"first_name": "Петр"}, + format="json", + ) + + self.assertEqual(response.status_code, 403) + other_user.refresh_from_db() + self.assertNotEqual(other_user.first_name, "Петр") + + def test_phone_number_is_hidden_from_other_users(self): + self.user.phone_number = "+79991234567" + self.user.save() + viewer = build_user(email="viewer@example.com") + self.client.force_authenticate(user=viewer) + + response = self.client.get(f"/auth/users/{self.user.id}/") + + self.assertEqual(response.status_code, 200) + self.assertNotIn("phone_number", response.data) + + def test_member_user_type_is_immutable_in_profile_update(self): + response = self.client.patch( + f"/auth/users/{self.user.id}/", + {"user_type": CustomUser.EXPERT}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.user_type, CustomUser.MEMBER) diff --git a/users/tests/test_signals.py b/users/tests/test_signals.py new file mode 100644 index 00000000..8983ab14 --- /dev/null +++ b/users/tests/test_signals.py @@ -0,0 +1,25 @@ +from django.test import TestCase + +from users.models import CustomUser + +from .helpers import attach_skill, build_skill, build_specialization, build_user + + +class UserSignalsTests(TestCase): + def test_dataset_migration_flag_is_enabled_when_specialization_and_skills_exist(self): + user = build_user(email="dataset@example.com") + user.v2_speciality = build_specialization() + attach_skill(user, build_skill()) + + with self.captureOnCommitCallbacks(execute=True): + user.save() + user.refresh_from_db() + + self.assertTrue(user.dataset_migration_applied) + + def test_role_signal_creates_expected_profile_for_non_member_types(self): + mentor = build_user(email="mentor@example.com", user_type=CustomUser.MENTOR) + investor = build_user(email="investor@example.com", user_type=CustomUser.INVESTOR) + + self.assertTrue(hasattr(mentor, "mentor")) + self.assertTrue(hasattr(investor, "investor")) diff --git a/users/tests/test_skill_confirmations_api.py b/users/tests/test_skill_confirmations_api.py new file mode 100644 index 00000000..56e80ce0 --- /dev/null +++ b/users/tests/test_skill_confirmations_api.py @@ -0,0 +1,69 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from users.models import UserSkillConfirmation + +from .helpers import attach_skill, build_skill, build_user + + +class UserSkillConfirmationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="skill-owner@example.com") + self.confirming_user = build_user(email="confirmer@example.com") + self.skill = build_skill("Python") + self.skill_to_object = attach_skill(self.user, self.skill) + + def test_user_can_confirm_another_user_skill(self): + self.client.force_authenticate(user=self.confirming_user) + + response = self.client.post( + f"/auth/users/{self.user.id}/approve_skill/{self.skill.id}/" + ) + + self.assertEqual(response.status_code, 201) + self.assertTrue( + UserSkillConfirmation.objects.filter( + skill_to_object=self.skill_to_object, + confirmed_by=self.confirming_user, + ).exists() + ) + + def test_user_cannot_confirm_own_skill(self): + self.client.force_authenticate(user=self.user) + + response = self.client.post( + f"/auth/users/{self.user.id}/approve_skill/{self.skill.id}/" + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(UserSkillConfirmation.objects.exists()) + + def test_user_can_delete_own_skill_confirmation(self): + UserSkillConfirmation.objects.create( + skill_to_object=self.skill_to_object, + confirmed_by=self.confirming_user, + ) + self.client.force_authenticate(user=self.confirming_user) + + response = self.client.delete( + f"/auth/users/{self.user.id}/approve_skill/{self.skill.id}/" + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse(UserSkillConfirmation.objects.exists()) + + def test_user_cannot_delete_another_user_skill_confirmation(self): + other_user = build_user(email="other-confirmer@example.com") + UserSkillConfirmation.objects.create( + skill_to_object=self.skill_to_object, + confirmed_by=other_user, + ) + self.client.force_authenticate(user=self.confirming_user) + + response = self.client.delete( + f"/auth/users/{self.user.id}/approve_skill/{self.skill.id}/" + ) + + self.assertEqual(response.status_code, 404) + self.assertTrue(UserSkillConfirmation.objects.exists()) diff --git a/users/tests/test_user_lists_api.py b/users/tests/test_user_lists_api.py new file mode 100644 index 00000000..80a66b66 --- /dev/null +++ b/users/tests/test_user_lists_api.py @@ -0,0 +1,113 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from users.models import LikesOnProject + +from .helpers import ( + add_user_to_program, + attach_skill, + build_partner_program, + build_project, + build_skill, + build_user, +) + + +class PublicUserListAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_public_users_can_be_filtered_by_fullname(self): + matched_user = build_user( + email="matched@example.com", + first_name="Алексей", + last_name="Петров", + ) + build_user( + email="not-matched@example.com", + first_name="Иван", + last_name="Сидоров", + ) + + response = self.client.get("/auth/public-users/?fullname=Алексей Петров") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertEqual(returned_ids, {matched_user.id}) + + def test_public_users_can_be_filtered_by_skill(self): + matched_user = build_user(email="skilled@example.com") + other_user = build_user(email="unskilled@example.com") + skill = build_skill("Django") + attach_skill(matched_user, skill) + + response = self.client.get("/auth/public-users/?skills__contains=Django") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertIn(matched_user.id, returned_ids) + self.assertNotIn(other_user.id, returned_ids) + + def test_public_users_can_be_filtered_by_partner_program(self): + matched_user = build_user(email="program-user@example.com") + other_user = build_user(email="not-program-user@example.com") + program = build_partner_program() + add_user_to_program(matched_user, program) + + response = self.client.get(f"/auth/public-users/?partner_program={program.id}") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertIn(matched_user.id, returned_ids) + self.assertNotIn(other_user.id, returned_ids) + + +class UserProjectsAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="projects-user@example.com") + self.client.force_authenticate(user=self.user) + + def test_user_projects_returns_leader_and_collaborator_projects(self): + leader_project = build_project(self.user, name="Leader project") + collaborator_leader = build_user(email="collaborator-leader@example.com") + collaborator_project = build_project( + collaborator_leader, + name="Collaborator project", + ) + collaborator_project.collaborator_set.create(user=self.user, role="Member") + + response = self.client.get("/auth/users/projects/") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertSetEqual(returned_ids, {leader_project.id, collaborator_project.id}) + + def test_user_leader_projects_returns_only_owned_projects(self): + leader_project = build_project(self.user, name="Leader project") + other_leader = build_user(email="other-leader@example.com") + other_project = build_project(other_leader, name="Other project") + other_project.collaborator_set.create(user=self.user, role="Member") + + response = self.client.get("/auth/users/projects/leader/") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertSetEqual(returned_ids, {leader_project.id}) + + def test_liked_projects_returns_only_active_likes(self): + liked_project = build_project(self.user, name="Liked project") + unliked_project = build_project(self.user, name="Unliked project") + LikesOnProject.objects.create(user=self.user, project=liked_project) + LikesOnProject.objects.create( + user=self.user, + project=unliked_project, + is_liked=False, + ) + + response = self.client.get("/auth/users/liked/") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data} + self.assertIn(liked_project.id, returned_ids) + self.assertNotIn(unliked_project.id, returned_ids) diff --git a/users/validators.py b/users/validators.py index 7d6e506c..7506ae33 100644 --- a/users/validators.py +++ b/users/validators.py @@ -9,11 +9,11 @@ def user_birthday_validator(birthday): """returns true if person > 12 years old""" - if (timezone.now().date() - birthday).days >= 12 * 365: - return True # check if person is > 100 years old if (timezone.now().date() - birthday).days >= 100 * 365: raise ValidationError("Человек старше 100 лет") + if (timezone.now().date() - birthday).days >= 12 * 365: + return True raise ValidationError("Человек младше 12 лет")