diff --git a/docs/public-profiles-api.md b/docs/public-profiles-api.md new file mode 100644 index 00000000..907b1750 --- /dev/null +++ b/docs/public-profiles-api.md @@ -0,0 +1,99 @@ +# Public Profiles API + +## Назначение + +API предоставляет авторизованным пользователям каталог участников и безопасный публичный профиль. Он не переиспользует `UserDetailSerializer`: собственный профиль содержит контактные данные, которые нельзя раскрывать другим пользователям. + +Заполнение профиля добровольное. Onboarding и ограничения доступа по заполненности профиля не добавлялись. + +## Endpoints + +| Метод | Endpoint | Назначение | +| --- | --- | --- | +| `GET` | `/auth/profiles/` | Каталог активных пользователей | +| `GET` | `/auth/profiles//` | Публичный профиль активного пользователя | +| `GET` | `/auth/users/current/` | Приватные данные текущего пользователя | +| `PATCH` | `/auth/users//` | Редактирование собственного профиля | + +Оба публичных endpoint требуют активную авторизованную сессию. Неизвестный или неактивный пользователь возвращает `404`. + +## Каталог + +Ответ использует limit/offset-пагинацию: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 42, + "first_name": "Ирина", + "last_name": "Орлова", + "avatar": null, + "city": "Казань", + "user_type": 1, + "user_type_label": "Участник", + "specialization": { + "id": 7, + "name": "Аналитик", + "category": {"id": 2, "name": "Аналитика"} + }, + "skills": [ + { + "id": 5, + "name": "Python", + "category": {"id": 3, "name": "Разработка"} + } + ] + } + ] +} +``` + +Поддерживаются параметры: + +* `limit`, `offset`; +* `search` — имя, фамилия и полное имя в обоих порядках; +* `user_type` — идентификатор типа пользователя; +* `specialization` — идентификатор специализации; +* `skill` — идентификатор навыка. + +Порядок стабилен: имя, фамилия, идентификатор. Связанные навыки не создают дубли пользователей. + +## Публичный detail + +Detail дополняет карточку следующими полями: + +* `patronymic`; +* `about_me`; +* `links`; +* `education`; +* `work_experience`; +* `user_languages`; +* `achievements` и безопасные метаданные файлов достижений. + +Публичный JSON построен через явный allow-list. Он не содержит на любом уровне: + +* email и телефон; +* дату рождения; +* пароль и токены; +* `is_staff`, `is_superuser`, `is_active`; +* onboarding, verification и другие служебные статусы; +* online-статус и технические даты; +* идентификатор владельца загруженного файла. + +## Редактирование собственного профиля + +Существующий `PATCH /auth/users//` сохраняется. Object permission разрешает изменение только владельцу; административное расширение прав не добавлялось. Email, состояние аккаунта, onboarding и административные признаки не изменяются через serializer профиля. + +Вложенные `education`, `work_experience`, `user_languages`, `achievements` и `links` передаются полным актуальным набором. Обновление выполняется атомарно: ошибка вложенной коллекции откатывает остальные изменения. `avatar` остаётся URL, полученным после загрузки через `/files/`. + +## Производительность + +Список заранее загружает специализацию, категории и навыки. Detail дополнительно загружает ссылки, образование, опыт, языки, достижения и их файлы. Размер списка не увеличивает число SQL-запросов. + +## Границы + +В API не добавлялись рейтинги, статистика, CV, сообщения, экспорт, изменения регистрации или onboarding. Модели и миграции не изменялись. diff --git a/users/public_profile_filters.py b/users/public_profile_filters.py new file mode 100644 index 00000000..934a68ee --- /dev/null +++ b/users/public_profile_filters.py @@ -0,0 +1,31 @@ +from django.db.models import Q, QuerySet +from django_filters import rest_framework as filters + +from users.models import CustomUser + + +class PublicProfileFilter(filters.FilterSet): + """Фильтры каталога участников без обращения к приватным полям профиля.""" + + search = filters.CharFilter(method="filter_search") + user_type = filters.NumberFilter(field_name="user_type") + specialization = filters.NumberFilter(field_name="v2_speciality_id") + skill = filters.NumberFilter(field_name="skills__skill_id") + + @staticmethod + def filter_search( + queryset: QuerySet[CustomUser], name: str, value: str + ) -> QuerySet[CustomUser]: + """Ищет каждую часть запроса в имени или фамилии в любом порядке.""" + + del name + terms = [term for term in value.split() if term] + for term in terms: + queryset = queryset.filter( + Q(first_name__icontains=term) | Q(last_name__icontains=term) + ) + return queryset + + class Meta: + model = CustomUser + fields = ("user_type", "specialization", "skill") diff --git a/users/public_profile_selectors.py b/users/public_profile_selectors.py new file mode 100644 index 00000000..651ede6b --- /dev/null +++ b/users/public_profile_selectors.py @@ -0,0 +1,48 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.models import Prefetch, QuerySet + +from core.models import SkillToObject +from users.models import ( + CustomUser, + UserAchievement, + UserEducation, + UserLanguages, + UserLink, + UserWorkExperience, +) + + +def get_public_profiles_queryset(*, detailed: bool = False) -> QuerySet[CustomUser]: + """Возвращает активные профили с данными, разрешёнными публичным контрактом.""" + + user_content_type = ContentType.objects.get_for_model(CustomUser) + skills = ( + SkillToObject.objects.filter(content_type=user_content_type) + .select_related("skill", "skill__category") + .order_by("skill__name", "skill_id") + ) + + queryset = ( + CustomUser.objects.filter(is_active=True) + .select_related("v2_speciality", "v2_speciality__category") + .prefetch_related(Prefetch("skills", queryset=skills, to_attr="public_skills")) + ) + + if detailed: + queryset = queryset.prefetch_related( + Prefetch( + "links", queryset=UserLink.objects.order_by("id"), to_attr="public_links" + ), + Prefetch("education", queryset=UserEducation.objects.order_by("id")), + Prefetch( + "work_experience", + queryset=UserWorkExperience.objects.order_by("id"), + ), + Prefetch("user_languages", queryset=UserLanguages.objects.order_by("id")), + Prefetch( + "achievements", + queryset=UserAchievement.objects.order_by("id").prefetch_related("files"), + ), + ) + + return queryset.distinct().order_by("first_name", "last_name", "id") diff --git a/users/public_profile_serializers.py b/users/public_profile_serializers.py new file mode 100644 index 00000000..aa9e2c9a --- /dev/null +++ b/users/public_profile_serializers.py @@ -0,0 +1,137 @@ +from rest_framework import serializers + +from core.models import SkillCategory, Specialization, SpecializationCategory +from files.models import UserFile +from users.models import ( + CustomUser, + UserAchievement, + UserEducation, + UserLanguages, + UserWorkExperience, +) + + +class PublicProfileCategorySerializer(serializers.ModelSerializer): + class Meta: + model = SkillCategory + fields = ("id", "name") + + +class PublicProfileSpecializationCategorySerializer(serializers.ModelSerializer): + class Meta: + model = SpecializationCategory + fields = ("id", "name") + + +class PublicProfileSpecializationSerializer(serializers.ModelSerializer): + category = PublicProfileSpecializationCategorySerializer(read_only=True) + + class Meta: + model = Specialization + fields = ("id", "name", "category") + + +class PublicProfileFileSerializer(serializers.ModelSerializer): + class Meta: + model = UserFile + fields = ("link", "name", "extension", "mime_type", "size") + + +class PublicProfileAchievementSerializer(serializers.ModelSerializer): + files = PublicProfileFileSerializer(many=True, read_only=True) + + class Meta: + model = UserAchievement + fields = ("id", "title", "status", "year", "files") + + +class PublicProfileEducationSerializer(serializers.ModelSerializer): + class Meta: + model = UserEducation + fields = ( + "organization_name", + "description", + "entry_year", + "completion_year", + "education_level", + "education_status", + ) + + +class PublicProfileWorkExperienceSerializer(serializers.ModelSerializer): + class Meta: + model = UserWorkExperience + fields = ( + "organization_name", + "description", + "entry_year", + "completion_year", + "job_position", + ) + + +class PublicProfileLanguageSerializer(serializers.ModelSerializer): + class Meta: + model = UserLanguages + fields = ("language", "language_level") + + +class PublicProfileListSerializer(serializers.ModelSerializer): + user_type_label = serializers.CharField( + source="get_user_type_display", read_only=True + ) + specialization = PublicProfileSpecializationSerializer( + source="v2_speciality", read_only=True + ) + skills = serializers.SerializerMethodField() + + class Meta: + model = CustomUser + # Публичный контракт строится только через явный allow-list. + fields = ( + "id", + "first_name", + "last_name", + "avatar", + "city", + "user_type", + "user_type_label", + "specialization", + "skills", + ) + + @staticmethod + def get_skills(user: CustomUser) -> list[dict]: + """Сериализует только справочные данные заранее загруженных навыков.""" + + return [ + { + "id": relation.skill_id, + "name": relation.skill.name, + "category": PublicProfileCategorySerializer(relation.skill.category).data, + } + for relation in getattr(user, "public_skills", []) + ] + + +class PublicProfileDetailSerializer(PublicProfileListSerializer): + education = PublicProfileEducationSerializer(many=True, read_only=True) + work_experience = PublicProfileWorkExperienceSerializer(many=True, read_only=True) + user_languages = PublicProfileLanguageSerializer(many=True, read_only=True) + achievements = PublicProfileAchievementSerializer(many=True, read_only=True) + links = serializers.SerializerMethodField() + + class Meta(PublicProfileListSerializer.Meta): + fields = PublicProfileListSerializer.Meta.fields + ( + "patronymic", + "about_me", + "links", + "education", + "work_experience", + "user_languages", + "achievements", + ) + + @staticmethod + def get_links(user: CustomUser) -> list[str]: + return [link.link for link in getattr(user, "public_links", [])] diff --git a/users/public_profile_views.py b/users/public_profile_views.py new file mode 100644 index 00000000..990810e5 --- /dev/null +++ b/users/public_profile_views.py @@ -0,0 +1,34 @@ +from django_filters import rest_framework as filters +from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.permissions import IsAuthenticated + +from users.pagination import UsersPagination +from users.public_profile_filters import PublicProfileFilter +from users.public_profile_selectors import get_public_profiles_queryset +from users.public_profile_serializers import ( + PublicProfileDetailSerializer, + PublicProfileListSerializer, +) + + +class PublicProfileListView(ListAPIView): + """Каталог доступных профилей для авторизованных пользователей.""" + + serializer_class = PublicProfileListSerializer + permission_classes = [IsAuthenticated] + pagination_class = UsersPagination + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = PublicProfileFilter + + def get_queryset(self): + return get_public_profiles_queryset() + + +class PublicProfileDetailView(RetrieveAPIView): + """Безопасный публичный профиль по идентификатору пользователя.""" + + serializer_class = PublicProfileDetailSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return get_public_profiles_queryset(detailed=True) diff --git a/users/tests/test_public_profiles_api.py b/users/tests/test_public_profiles_api.py new file mode 100644 index 00000000..20efd2db --- /dev/null +++ b/users/tests/test_public_profiles_api.py @@ -0,0 +1,358 @@ +from django.contrib.contenttypes.models import ContentType +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from core.models import SkillToObject +from users.models import ( + CustomUser, + UserAchievement, + UserEducation, + UserLanguages, + UserLink, + UserWorkExperience, +) + +from .helpers import ( + attach_skill, + build_skill, + build_specialization, + build_user, + build_user_file, +) + + +PUBLIC_LIST_URL = "/auth/profiles/" + + +class PublicProfileListAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.viewer = build_user(email="viewer@example.com") + self.client.force_authenticate(self.viewer) + + def test_authenticated_user_can_list_profiles(self): + target = build_user( + email="target@example.com", + first_name="Анна", + last_name="Смирнова", + city="Москва", + ) + + response = self.client.get(PUBLIC_LIST_URL) + + self.assertEqual(response.status_code, 200) + target_item = next( + item for item in response.data["results"] if item["id"] == target.id + ) + self.assertEqual(target_item["first_name"], "Анна") + self.assertEqual(target_item["city"], "Москва") + + def test_anonymous_user_cannot_list_profiles(self): + self.client.force_authenticate(user=None) + + response = self.client.get(PUBLIC_LIST_URL) + + self.assertEqual(response.status_code, 401) + + def test_pagination_returns_stable_non_overlapping_pages(self): + for index in range(12): + build_user( + email=f"member-{index}@example.com", + first_name="Имя{:02d}".format(index), + last_name="Участник", + ) + + first = self.client.get(PUBLIC_LIST_URL, {"limit": 5, "offset": 0}) + second = self.client.get(PUBLIC_LIST_URL, {"limit": 5, "offset": 5}) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + first_ids = [item["id"] for item in first.data["results"]] + second_ids = [item["id"] for item in second.data["results"]] + self.assertEqual(len(first_ids), 5) + self.assertEqual(len(second_ids), 5) + self.assertFalse(set(first_ids) & set(second_ids)) + + def test_search_matches_first_last_and_full_name_in_both_orders(self): + target = build_user( + email="search@example.com", first_name="Мария", last_name="Петрова" + ) + build_user( + email="unrelated@example.com", first_name="Алексей", last_name="Сидоров" + ) + + for query in ("Мария", "Петрова", "Мария Петрова", "Петрова Мария"): + with self.subTest(query=query): + response = self.client.get(PUBLIC_LIST_URL, {"search": query}) + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], [target.id] + ) + + def test_confirmed_role_specialization_and_skill_filters_work(self): + specialization = build_specialization("Backend") + skill = build_skill("Python") + target = build_user( + email="filtered@example.com", + user_type=CustomUser.EXPERT, + v2_speciality=specialization, + ) + attach_skill(target, skill) + build_user(email="other@example.com") + + response = self.client.get( + PUBLIC_LIST_URL, + { + "user_type": CustomUser.EXPERT, + "specialization": specialization.id, + "skill": skill.id, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data["results"]], [target.id]) + + def test_multiple_skills_do_not_duplicate_profile(self): + target = build_user(email="skilled@example.com") + attach_skill(target, build_skill("Python")) + attach_skill(target, build_skill("Django")) + + response = self.client.get(PUBLIC_LIST_URL) + + result_ids = [item["id"] for item in response.data["results"]] + self.assertEqual(result_ids.count(target.id), 1) + + def test_inactive_users_are_excluded(self): + inactive = build_user(email="inactive@example.com", is_active=False) + + response = self.client.get(PUBLIC_LIST_URL) + + self.assertNotIn(inactive.id, [item["id"] for item in response.data["results"]]) + + def test_empty_result_has_paginated_shape(self): + response = self.client.get(PUBLIC_LIST_URL, {"search": "НетТакогоПользователя"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 0) + self.assertEqual(response.data["results"], []) + + def test_list_query_count_does_not_grow_with_profiles_or_skills(self): + content_type = ContentType.objects.get_for_model(CustomUser) + skills = [build_skill(f"Навык {index}") for index in range(3)] + for index in range(8): + user = build_user(email=f"queries-{index}@example.com") + SkillToObject.objects.bulk_create( + [ + SkillToObject( + skill=skill, + content_type=content_type, + object_id=user.id, + ) + for skill in skills + ] + ) + + with CaptureQueriesContext(connection) as queries: + response = self.client.get(PUBLIC_LIST_URL, {"limit": 20}) + + self.assertEqual(response.status_code, 200) + self.assertLessEqual(len(queries), 4) + + +class PublicProfileDetailAPITests(TestCase): + forbidden_keys = { + "email", + "phone", + "phone_number", + "birthday", + "password", + "is_active", + "is_staff", + "is_superuser", + "is_online", + "last_login", + "date_joined", + "onboarding_stage", + "verification_date", + "ordering_score", + "dataset_migration_applied", + "user", + } + + def setUp(self): + self.client = APIClient() + self.viewer = build_user(email="viewer-detail@example.com") + self.client.force_authenticate(self.viewer) + + def _assert_forbidden_keys_absent(self, value): + if isinstance(value, dict): + self.assertFalse(self.forbidden_keys & set(value.keys())) + for nested in value.values(): + self._assert_forbidden_keys_absent(nested) + elif isinstance(value, list): + for nested in value: + self._assert_forbidden_keys_absent(nested) + + def test_public_detail_returns_only_allow_list_in_expected_format(self): + specialization = build_specialization("Аналитик") + user = build_user( + email="private@example.com", + first_name="Ирина", + last_name="Орлова", + patronymic="Сергеевна", + city="Казань", + about_me="Работаю с данными", + phone_number="+79991234567", + v2_speciality=specialization, + ) + attach_skill(user, build_skill("Аналитика")) + UserLink.objects.create(user=user, link="https://example.com/irina") + UserEducation.objects.create(user=user, organization_name="Университет") + UserWorkExperience.objects.create(user=user, organization_name="Компания") + UserLanguages.objects.create( + user=user, language="Английский", language_level="B2" + ) + achievement = UserAchievement.objects.create( + user=user, title="Хакатон", status="Победитель", year=2025 + ) + achievement.files.add( + build_user_file(user, link="https://cdn.example.com/diploma.pdf") + ) + + response = self.client.get(f"/auth/profiles/{user.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + set(response.data.keys()), + { + "id", + "first_name", + "last_name", + "avatar", + "city", + "user_type", + "user_type_label", + "specialization", + "skills", + "patronymic", + "about_me", + "links", + "education", + "work_experience", + "user_languages", + "achievements", + }, + ) + self.assertEqual(response.data["specialization"]["id"], specialization.id) + self.assertEqual(response.data["achievements"][0]["files"][0]["name"], "file") + self._assert_forbidden_keys_absent(response.data) + + def test_anonymous_user_cannot_open_public_detail(self): + user = build_user(email="anonymous-target@example.com") + self.client.force_authenticate(user=None) + + response = self.client.get(f"/auth/profiles/{user.id}/") + + self.assertEqual(response.status_code, 401) + + def test_unknown_profile_returns_404(self): + response = self.client.get("/auth/profiles/999999/") + + self.assertEqual(response.status_code, 404) + + def test_inactive_profile_returns_safe_404(self): + user = build_user(email="hidden@example.com", is_active=False) + + response = self.client.get(f"/auth/profiles/{user.id}/") + + self.assertEqual(response.status_code, 404) + self.assertNotContains(response, user.email, status_code=404) + + +class OwnProfileUpdateRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="owner-profile@example.com") + self.client.force_authenticate(self.user) + + def test_owner_updates_profile_and_avatar_url(self): + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "first_name": "Мария", + "avatar": "https://cdn.example.com/avatar.webp", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, "Мария") + self.assertEqual(self.user.avatar, "https://cdn.example.com/avatar.webp") + + def test_user_cannot_update_another_profile(self): + other = build_user(email="other-profile@example.com") + + response = self.client.patch( + f"/auth/users/{other.id}/", {"first_name": "Подмена"}, format="json" + ) + + self.assertEqual(response.status_code, 403) + other.refresh_from_db() + self.assertNotEqual(other.first_name, "Подмена") + + def test_auth_and_service_fields_cannot_be_changed(self): + original_email = self.user.email + original_stage = self.user.onboarding_stage + + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "email": "attacker@example.com", + "is_active": False, + "is_staff": True, + "is_superuser": True, + "onboarding_stage": None, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.email, original_email) + self.assertTrue(self.user.is_active) + self.assertFalse(self.user.is_staff) + self.assertFalse(self.user.is_superuser) + self.assertEqual(self.user.onboarding_stage, original_stage) + + def test_invalid_specialization_id_is_rejected(self): + response = self.client.patch( + f"/auth/users/{self.user.id}/", + {"v2_speciality_id": 999999}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + def test_invalid_nested_collection_rolls_back_whole_profile_update(self): + self.user.first_name = "До изменения" + self.user.save(update_fields=["first_name"]) + + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "first_name": "Не должно сохраниться", + "user_languages": [ + {"language": "Английский", "language_level": "B1"}, + {"language": "Английский", "language_level": "B2"}, + ], + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, "До изменения") + self.assertFalse(UserLanguages.objects.filter(user=self.user).exists()) diff --git a/users/urls.py b/users/urls.py index 33477538..998adf36 100644 --- a/users/urls.py +++ b/users/urls.py @@ -29,10 +29,17 @@ UserCVDownload, UserCVMailing, ) +from users.public_profile_views import PublicProfileDetailView, PublicProfileListView app_name = "users" urlpatterns = [ + path("profiles/", PublicProfileListView.as_view(), name="public-profile-list"), + path( + "profiles//", + PublicProfileDetailView.as_view(), + name="public-profile-detail", + ), path( "specialists/", SpecialistsList.as_view() ), # this url actually returns mentors, experts and investors