From 771ac1c47b3830ce63a8bdc455ad185c8cd27353 Mon Sep 17 00:00:00 2001 From: Fedor Date: Wed, 12 Aug 2026 13:16:48 +0300 Subject: [PATCH] Add typed social links API --- docs/public-profiles-api.md | 5 +- docs/typed-social-links-api.md | 65 +++++++ users/admin.py | 3 +- users/helpers.py | 11 +- users/migrations/0061_userlink_kind.py | 37 ++++ users/models.py | 21 +++ users/public_profile_selectors.py | 4 +- users/public_profile_serializers.py | 14 +- users/serializers.py | 30 ++++ users/social_links.py | 70 ++++++++ users/tests/test_public_profiles_api.py | 13 +- users/tests/test_social_links_api.py | 219 ++++++++++++++++++++++++ 12 files changed, 480 insertions(+), 12 deletions(-) create mode 100644 docs/typed-social-links-api.md create mode 100644 users/migrations/0061_userlink_kind.py create mode 100644 users/social_links.py create mode 100644 users/tests/test_social_links_api.py diff --git a/docs/public-profiles-api.md b/docs/public-profiles-api.md index 907b1750..b9af13a2 100644 --- a/docs/public-profiles-api.md +++ b/docs/public-profiles-api.md @@ -68,7 +68,7 @@ Detail дополняет карточку следующими полями: * `patronymic`; * `about_me`; -* `links`; +* `social_links` — словарь типизированных публичных ссылок; * `education`; * `work_experience`; * `user_languages`; @@ -84,6 +84,9 @@ Detail дополняет карточку следующими полями: * online-статус и технические даты; * идентификатор владельца загруженного файла. +Legacy-массив `links` в публичный detail не входит. Формат чтения и частичного +обновления типизированных ссылок описан в `docs/typed-social-links-api.md`. + ## Редактирование собственного профиля Существующий `PATCH /auth/users//` сохраняется. Object permission разрешает изменение только владельцу; административное расширение прав не добавлялось. Email, состояние аккаунта, onboarding и административные признаки не изменяются через serializer профиля. diff --git a/docs/typed-social-links-api.md b/docs/typed-social-links-api.md new file mode 100644 index 00000000..1dafed15 --- /dev/null +++ b/docs/typed-social-links-api.md @@ -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//` для владельца возвращают: + +```json +{ + "links": ["https://legacy.example.com/profile"], + "social_links": { + "telegram": "https://t.me/anna", + "github": "https://github.com/anna" + } +} +``` + +`GET /auth/profiles//` возвращает только `social_links`. Legacy-массив не входит +в безопасный публичный контракт и не раскрывается через новый endpoint. + +## Partial PATCH + +`PATCH /auth/users//` принимает объект `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 не меняются. diff --git a/users/admin.py b/users/admin.py index ff993c4f..170e817b 100644 --- a/users/admin.py +++ b/users/admin.py @@ -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) diff --git a/users/helpers.py b/users/helpers.py index b513be63..cff2cacb 100644 --- a/users/helpers.py +++ b/users/helpers.py @@ -249,9 +249,13 @@ 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( @@ -259,6 +263,7 @@ def update_links(links, pk): link=link, ) for link in links + if link not in typed_links ] ) diff --git a/users/migrations/0061_userlink_kind.py b/users/migrations/0061_userlink_kind.py new file mode 100644 index 00000000..3d9b4e95 --- /dev/null +++ b/users/migrations/0061_userlink_kind.py @@ -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", + ), + ), + ] diff --git a/users/models.py b/users/models.py index 02065c35..66990ee9 100644 --- a/users/models.py +++ b/users/models.py @@ -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}" @@ -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): diff --git a/users/public_profile_selectors.py b/users/public_profile_selectors.py index 651ede6b..98963a31 100644 --- a/users/public_profile_selectors.py +++ b/users/public_profile_selectors.py @@ -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( diff --git a/users/public_profile_serializers.py b/users/public_profile_serializers.py index aa9e2c9a..dfb034f8 100644 --- a/users/public_profile_serializers.py +++ b/users/public_profile_serializers.py @@ -119,13 +119,13 @@ 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", @@ -133,5 +133,11 @@ class Meta(PublicProfileListSerializer.Meta): ) @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 + } diff --git a/users/serializers.py b/users/serializers.py index c13754ed..fa0900c0 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -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 @@ -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() @@ -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: @@ -520,6 +544,7 @@ class Meta: "about_me", "avatar", "links", + "social_links", "city", "phone_number", "is_active", @@ -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") @@ -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 @@ -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) diff --git a/users/social_links.py b/users/social_links.py new file mode 100644 index 00000000..ee7b5a21 --- /dev/null +++ b/users/social_links.py @@ -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}, + ) diff --git a/users/tests/test_public_profiles_api.py b/users/tests/test_public_profiles_api.py index 20efd2db..0c611cea 100644 --- a/users/tests/test_public_profiles_api.py +++ b/users/tests/test_public_profiles_api.py @@ -208,7 +208,12 @@ def test_public_detail_returns_only_allow_list_in_expected_format(self): v2_speciality=specialization, ) attach_skill(user, build_skill("Аналитика")) - UserLink.objects.create(user=user, link="https://example.com/irina") + UserLink.objects.create(user=user, link="https://legacy.example.com/irina") + UserLink.objects.create( + user=user, + kind=UserLink.Kind.TELEGRAM, + link="https://t.me/irina", + ) UserEducation.objects.create(user=user, organization_name="Университет") UserWorkExperience.objects.create(user=user, organization_name="Компания") UserLanguages.objects.create( @@ -238,7 +243,7 @@ def test_public_detail_returns_only_allow_list_in_expected_format(self): "skills", "patronymic", "about_me", - "links", + "social_links", "education", "work_experience", "user_languages", @@ -246,6 +251,10 @@ def test_public_detail_returns_only_allow_list_in_expected_format(self): }, ) self.assertEqual(response.data["specialization"]["id"], specialization.id) + self.assertEqual( + response.data["social_links"], {"telegram": "https://t.me/irina"} + ) + self.assertNotContains(response, "legacy.example.com") self.assertEqual(response.data["achievements"][0]["files"][0]["name"], "file") self._assert_forbidden_keys_absent(response.data) diff --git a/users/tests/test_social_links_api.py b/users/tests/test_social_links_api.py new file mode 100644 index 00000000..3150e8ae --- /dev/null +++ b/users/tests/test_social_links_api.py @@ -0,0 +1,219 @@ +from django.db import IntegrityError, transaction +from django.test import TestCase +from rest_framework.test import APIClient + +from users.models import UserLink + +from .helpers import build_user + + +class SocialLinksAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="social-links@example.com") + self.client.force_authenticate(self.user) + self.url = f"/auth/users/{self.user.id}/" + + def test_owner_creates_and_reads_typed_social_links(self): + response = self.client.patch( + self.url, + { + "social_links": { + "telegram": "https://t.me/anna", + "vk": "https://vk.com/anna", + "github": "https://github.com/anna", + "linkedin": "https://linkedin.com/in/anna", + "website": "https://anna.dev", + } + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["social_links"], + { + "telegram": "https://t.me/anna", + "vk": "https://vk.com/anna", + "github": "https://github.com/anna", + "linkedin": "https://linkedin.com/in/anna", + "website": "https://anna.dev", + }, + ) + self.assertEqual( + UserLink.objects.filter(user=self.user, kind__isnull=False).count(), 5 + ) + + def test_patch_updates_only_present_keys_and_null_deletes_one_link(self): + UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.TELEGRAM, + link="https://t.me/old", + ) + UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.GITHUB, + link="https://github.com/anna", + ) + + response = self.client.patch( + self.url, + {"social_links": {"telegram": None}}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["social_links"], {"github": "https://github.com/anna"} + ) + self.assertFalse( + UserLink.objects.filter(user=self.user, kind=UserLink.Kind.TELEGRAM).exists() + ) + + def test_missing_social_links_does_not_change_typed_links(self): + link = UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.GITHUB, + link="https://github.com/anna", + ) + + response = self.client.patch(self.url, {"first_name": "Анна"}, format="json") + + self.assertEqual(response.status_code, 200) + link.refresh_from_db() + self.assertEqual(link.link, "https://github.com/anna") + + def test_unknown_kind_is_rejected_atomically(self): + self.user.first_name = "До изменения" + self.user.save(update_fields=["first_name"]) + + response = self.client.patch( + self.url, + { + "first_name": "Не должно сохраниться", + "social_links": { + "telegram": "https://t.me/anna", + "discord": "https://discord.com/users/anna", + }, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("discord", response.data["social_links"]) + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, "До изменения") + self.assertFalse(UserLink.objects.filter(user=self.user).exists()) + + def test_invalid_url_is_rejected_without_partial_update(self): + response = self.client.patch( + self.url, + { + "social_links": { + "telegram": "not-a-url", + "website": "https://valid.example.com", + } + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("telegram", response.data["social_links"]) + self.assertFalse(UserLink.objects.filter(user=self.user).exists()) + + def test_duplicate_urls_are_rejected_as_validation_error(self): + response = self.client.patch( + self.url, + { + "social_links": { + "github": "https://example.com/anna", + "website": "https://example.com/anna", + } + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("website", response.data["social_links"]) + self.assertFalse(UserLink.objects.filter(user=self.user).exists()) + + def test_legacy_links_remain_separate_and_legacy_patch_preserves_typed_links(self): + legacy = UserLink.objects.create( + user=self.user, link="https://legacy.example.com/profile" + ) + typed = UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.GITHUB, + link="https://github.com/anna", + ) + + response = self.client.patch( + self.url, + {"links": ["https://legacy.example.com/new"]}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(UserLink.objects.filter(pk=legacy.pk).exists()) + typed.refresh_from_db() + self.assertEqual(typed.link, "https://github.com/anna") + self.assertCountEqual( + response.data["links"], + ["https://legacy.example.com/new", "https://github.com/anna"], + ) + self.assertEqual( + response.data["social_links"], {"github": "https://github.com/anna"} + ) + + second_response = self.client.patch( + self.url, + {"links": response.data["links"]}, + format="json", + ) + self.assertEqual(second_response.status_code, 200) + self.assertEqual( + UserLink.objects.filter( + user=self.user, link="https://github.com/anna" + ).count(), + 1, + ) + + def test_two_legacy_links_are_allowed_but_typed_kind_is_unique(self): + UserLink.objects.create(user=self.user, link="https://legacy.example.com/one") + UserLink.objects.create(user=self.user, link="https://legacy.example.com/two") + UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.VK, + link="https://vk.com/one", + ) + + with self.assertRaises(IntegrityError), transaction.atomic(): + UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.VK, + link="https://vk.com/two", + ) + + self.assertEqual( + UserLink.objects.filter(user=self.user, kind__isnull=True).count(), 2 + ) + + def test_public_profile_returns_only_typed_links(self): + UserLink.objects.create( + user=self.user, link="https://legacy.example.com/private-shape" + ) + UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.LINKEDIN, + link="https://linkedin.com/in/anna", + ) + + response = self.client.get(f"/auth/profiles/{self.user.id}/") + + self.assertEqual(response.status_code, 200) + self.assertNotIn("links", response.data) + self.assertEqual( + response.data["social_links"], + {"linkedin": "https://linkedin.com/in/anna"}, + ) + self.assertNotContains(response, "legacy.example.com")