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
5 changes: 4 additions & 1 deletion docs/public-profiles-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Detail дополняет карточку следующими полями:

* `patronymic`;
* `about_me`;
* `links`;
* `social_links` — словарь типизированных публичных ссылок;
* `education`;
* `work_experience`;
* `user_languages`;
Expand All @@ -84,6 +84,9 @@ Detail дополняет карточку следующими полями:
* online-статус и технические даты;
* идентификатор владельца загруженного файла.

Legacy-массив `links` в публичный detail не входит. Формат чтения и частичного
обновления типизированных ссылок описан в `docs/typed-social-links-api.md`.

## Редактирование собственного профиля

Существующий `PATCH /auth/users/<user_id>/` сохраняется. Object permission разрешает изменение только владельцу; административное расширение прав не добавлялось. Email, состояние аккаунта, onboarding и административные признаки не изменяются через serializer профиля.
Expand Down
65 changes: 65 additions & 0 deletions docs/typed-social-links-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Типизированные социальные ссылки профиля

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

Типизированный контракт позволяет React показывать Telegram, ВКонтакте, GitHub,
LinkedIn и сайт с однозначными подписями. Он существует рядом с legacy-массивом
`links`, который продолжает использовать Angular.

## Модель

`UserLink.kind` допускает значения:

* `telegram`;
* `vk`;
* `github`;
* `linkedin`;
* `website`.

Поле nullable. Существующие строки после миграции сохраняют `kind = null`; миграция
не классифицирует, не удаляет и не дублирует их. Условный DB constraint разрешает
не более одной типизированной ссылки каждого вида у пользователя и не ограничивает
число legacy-записей с `kind = null`.

## Read contract

`GET /auth/users/current/` и `GET /auth/users/<id>/` для владельца возвращают:

```json
{
"links": ["https://legacy.example.com/profile"],
"social_links": {
"telegram": "https://t.me/anna",
"github": "https://github.com/anna"
}
}
```

`GET /auth/profiles/<id>/` возвращает только `social_links`. Legacy-массив не входит
в безопасный публичный контракт и не раскрывается через новый endpoint.

## Partial PATCH

`PATCH /auth/users/<id>/` принимает объект `social_links`. Обновление атомарно и
применяется только к переданным ключам:

```json
{
"social_links": {
"telegram": "https://t.me/new_name",
"linkedin": null
}
}
```

В примере Telegram создаётся или обновляется, LinkedIn удаляется, а GitHub,
ВКонтакте и сайт остаются без изменений. Отсутствие `social_links` в PATCH ничего
не меняет. Неизвестные ключи, некорректные и повторяющиеся URL возвращают `400`.

## Совместимость

Legacy `links` остаётся массивом всех URL, поэтому Angular продолжает видеть ссылки
без изменения формата. Его обновление затрагивает только записи с `kind = null`,
не удаляет типизированные ссылки и не создаёт их дубликаты, если Angular отправит
полученный массив обратно. Тип не кодируется порядком массива.
Angular, регистрация и onboarding не меняются.
3 changes: 2 additions & 1 deletion users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,9 @@ def file_link(self, obj):

@admin.register(UserLink)
class UserLinkAdmin(admin.ModelAdmin):
list_display = ("id", "user", "link")
list_display = ("id", "user", "kind", "link")
list_display_links = ("id", "user", "link")
list_filter = ("kind",)


@admin.register(Expert)
Expand Down
11 changes: 8 additions & 3 deletions users/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,16 +249,21 @@ def update_links(links, pk):
Bootleg version of updating links via user
"""

# delete all old links
UserLink.objects.filter(user_id=pk).delete()
# create new links
# Новый типизированный контракт хранится отдельно и не должен удаляться legacy PATCH.
typed_links = set(
UserLink.objects.filter(user_id=pk, kind__isnull=False).values_list(
"link", flat=True
)
)
UserLink.objects.filter(user_id=pk, kind__isnull=True).delete()
UserLink.objects.bulk_create(
[
UserLink(
user_id=pk,
link=link,
)
for link in links
if link not in typed_links
]
)

Expand Down
37 changes: 37 additions & 0 deletions users/migrations/0061_userlink_kind.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 4.2.11 on 2026-08-12

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("users", "0060_alter_userachievement_year"),
]

operations = [
migrations.AddField(
model_name="userlink",
name="kind",
field=models.CharField(
blank=True,
choices=[
("telegram", "Telegram"),
("vk", "ВКонтакте"),
("github", "GitHub"),
("linkedin", "LinkedIn"),
("website", "Сайт"),
],
max_length=16,
null=True,
),
),
migrations.AddConstraint(
model_name="userlink",
constraint=models.UniqueConstraint(
condition=models.Q(("kind__isnull", False)),
fields=("user", "kind"),
name="unique_typed_user_link_kind",
),
),
]
21 changes: 21 additions & 0 deletions users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,12 +533,25 @@ class UserLink(models.Model):
link: URLField instance of the user's link to some resource.
"""

class Kind(models.TextChoices):
TELEGRAM = "telegram", "Telegram"
VK = "vk", "ВКонтакте"
GITHUB = "github", "GitHub"
LINKEDIN = "linkedin", "LinkedIn"
WEBSITE = "website", "Сайт"

user = models.ForeignKey(
CustomUser,
on_delete=models.CASCADE,
related_name="links",
)
link = models.URLField()
kind = models.CharField(
max_length=16,
choices=Kind.choices,
null=True,
blank=True,
)

def __str__(self):
return f"UserLink<{self.id}> - {self.user.first_name} {self.user.last_name}"
Expand All @@ -547,6 +560,14 @@ class Meta(TypedModelMeta):
verbose_name = "Ссылка пользователя"
verbose_name_plural = "Ссылки пользователей"
unique_together = ("user", "link")
constraints = [
# Legacy-ссылки остаются без типа; ограничение применяется только к новому контракту.
models.UniqueConstraint(
fields=("user", "kind"),
condition=models.Q(kind__isnull=False),
name="unique_typed_user_link_kind",
)
]


class AbstractUserExperience(models.Model):
Expand Down
4 changes: 3 additions & 1 deletion users/public_profile_selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ def get_public_profiles_queryset(*, detailed: bool = False) -> QuerySet[CustomUs
if detailed:
queryset = queryset.prefetch_related(
Prefetch(
"links", queryset=UserLink.objects.order_by("id"), to_attr="public_links"
"links",
queryset=UserLink.objects.filter(kind__isnull=False).order_by("kind"),
to_attr="public_social_links",
),
Prefetch("education", queryset=UserEducation.objects.order_by("id")),
Prefetch(
Expand Down
14 changes: 10 additions & 4 deletions users/public_profile_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,19 +119,25 @@ class PublicProfileDetailSerializer(PublicProfileListSerializer):
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()
social_links = serializers.SerializerMethodField()

class Meta(PublicProfileListSerializer.Meta):
fields = PublicProfileListSerializer.Meta.fields + (
"patronymic",
"about_me",
"links",
"social_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", [])]
def get_social_links(user: CustomUser) -> dict[str, str]:
"""Возвращает только типизированные ссылки без внутренних полей модели."""

return {
link.kind: link.link
for link in getattr(user, "public_social_links", [])
if link.kind is not None
}
30 changes: 30 additions & 0 deletions users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
UserSkillConfirmation,
UserWorkExperience,
)
from users.social_links import SocialLinksField, get_social_links, update_social_links
from users.utils import normalize_user_phone
from users.validators import specialization_exists_validator

Expand Down Expand Up @@ -459,6 +460,7 @@ class UserDetailSerializer(
work_experience = UserWorkExperienceSerializer(required=False, many=True)
user_languages = UserLanguagesSerializer(required=False, many=True)
links = serializers.SerializerMethodField()
social_links = SocialLinksField(required=False, write_only=True)
is_online = serializers.SerializerMethodField()
projects = serializers.SerializerMethodField()
programs = serializers.SerializerMethodField()
Expand Down Expand Up @@ -492,6 +494,28 @@ def get_programs(self, user: CustomUser):
def get_links(cls, user: CustomUser):
return [user_link.link for user_link in user.links.all()]

def validate_social_links(self, value: dict[str, str | None]):
"""Не допускает один URL одновременно в разных legacy/typed записях."""

instance = self.instance
errors: dict[str, str] = {}
seen_urls: dict[str, str] = {}
for kind, link in value.items():
if link is None:
continue
if link in seen_urls:
errors[kind] = "Эта ссылка уже указана для другого типа."
continue
seen_urls[link] = kind
if (
instance is not None
and instance.links.filter(link=link).exclude(kind=kind).exists()
):
errors[kind] = "Эта ссылка уже сохранена в профиле."
if errors:
raise serializers.ValidationError(errors)
return value

def get_is_online(self, user: CustomUser):
request = self.context.get("request")
if request and request.user.is_authenticated and request.user.id == user.id:
Expand Down Expand Up @@ -520,6 +544,7 @@ class Meta:
"about_me",
"avatar",
"links",
"social_links",
"city",
"phone_number",
"is_active",
Expand All @@ -543,6 +568,7 @@ def update(self, instance, validated_data):
IMMUTABLE_FIELDS = ("email", "is_active", "password")
USER_TYPE_FIELDS = ("member", "investor", "expert", "mentor")
RELATED_FIELDS = ("achievements",)
social_links = validated_data.pop("social_links", None)

if instance.user_type == CustomUser.MEMBER:
IMMUTABLE_FIELDS = ("email", "user_type", "is_active", "password")
Expand Down Expand Up @@ -630,6 +656,9 @@ def update(self, instance, validated_data):

instance.save()

if social_links is not None:
update_social_links(instance, social_links)

return instance

@transaction.atomic
Expand Down Expand Up @@ -730,6 +759,7 @@ def to_representation(self, instance) -> dict[str, Any]:
is available only to the profile owner (used for CV).
"""
representation = super().to_representation(instance)
representation["social_links"] = get_social_links(instance)
request = self.context.get("request")
if request and request.user != instance:
representation.pop("phone_number", None)
Expand Down
70 changes: 70 additions & 0 deletions users/social_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from collections.abc import Mapping

from django.db import transaction
from rest_framework import serializers

from users.models import CustomUser, UserLink


class SocialLinksField(serializers.Field):
"""Проверяет частичное обновление типизированных социальных ссылок."""

default_error_messages = {
"not_object": "Ожидается объект социальных ссылок.",
"unknown_kind": "Неизвестный тип социальной ссылки.",
"invalid_url": "Укажите корректный URL.",
}

def to_internal_value(self, data):
if not isinstance(data, Mapping):
self.fail("not_object")

allowed_kinds = set(UserLink.Kind.values)
unknown_kinds = set(data) - allowed_kinds
if unknown_kinds:
raise serializers.ValidationError(
{
kind: self.error_messages["unknown_kind"]
for kind in sorted(unknown_kinds)
}
)

url_field = serializers.URLField(allow_null=True)
result: dict[str, str | None] = {}
errors: dict[str, str] = {}
for kind, value in data.items():
try:
result[kind] = url_field.run_validation(value)
except serializers.ValidationError:
errors[kind] = self.error_messages["invalid_url"]

if errors:
raise serializers.ValidationError(errors)
return result

def to_representation(self, value):
return value


def get_social_links(user: CustomUser) -> dict[str, str]:
"""Возвращает словарь типизированных ссылок текущего пользователя."""

return {link.kind: link.link for link in user.links.all() if link.kind is not None}


@transaction.atomic
def update_social_links(
user: CustomUser,
social_links: dict[str, str | None],
) -> None:
"""Обновляет только переданные типы, сохраняя legacy-ссылки и остальные ключи."""

for kind, link in social_links.items():
if link is None:
UserLink.objects.filter(user=user, kind=kind).delete()
continue
UserLink.objects.update_or_create(
user=user,
kind=kind,
defaults={"link": link},
)
Loading
Loading