Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions docs/public-profiles-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Public Profiles API

## Назначение

API предоставляет авторизованным пользователям каталог участников и безопасный публичный профиль. Он не переиспользует `UserDetailSerializer`: собственный профиль содержит контактные данные, которые нельзя раскрывать другим пользователям.

Заполнение профиля добровольное. Onboarding и ограничения доступа по заполненности профиля не добавлялись.

## Endpoints

| Метод | Endpoint | Назначение |
| --- | --- | --- |
| `GET` | `/auth/profiles/` | Каталог активных пользователей |
| `GET` | `/auth/profiles/<user_id>/` | Публичный профиль активного пользователя |
| `GET` | `/auth/users/current/` | Приватные данные текущего пользователя |
| `PATCH` | `/auth/users/<user_id>/` | Редактирование собственного профиля |

Оба публичных 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/<user_id>/` сохраняется. Object permission разрешает изменение только владельцу; административное расширение прав не добавлялось. Email, состояние аккаунта, onboarding и административные признаки не изменяются через serializer профиля.

Вложенные `education`, `work_experience`, `user_languages`, `achievements` и `links` передаются полным актуальным набором. Обновление выполняется атомарно: ошибка вложенной коллекции откатывает остальные изменения. `avatar` остаётся URL, полученным после загрузки через `/files/`.

## Производительность

Список заранее загружает специализацию, категории и навыки. Detail дополнительно загружает ссылки, образование, опыт, языки, достижения и их файлы. Размер списка не увеличивает число SQL-запросов.

## Границы

В API не добавлялись рейтинги, статистика, CV, сообщения, экспорт, изменения регистрации или onboarding. Модели и миграции не изменялись.
31 changes: 31 additions & 0 deletions users/public_profile_filters.py
Original file line number Diff line number Diff line change
@@ -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")
48 changes: 48 additions & 0 deletions users/public_profile_selectors.py
Original file line number Diff line number Diff line change
@@ -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")
137 changes: 137 additions & 0 deletions users/public_profile_serializers.py
Original file line number Diff line number Diff line change
@@ -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", [])]
34 changes: 34 additions & 0 deletions users/public_profile_views.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading