From 888d4fe17a3a51c59029816f9d0816b37b6f02f6 Mon Sep 17 00:00:00 2001 From: Toksi Date: Thu, 9 Oct 2025 13:11:27 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D1=8B=20"?= =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8",=20"=D0=A0?= =?UTF-8?q?=D0=B5=D1=81=D1=83=D1=80=D1=81=D1=8B",=20"=D0=9A=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8=D0=9F=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/admin.py | 51 +++- ...ojectcompany_project_companies_and_more.py | 161 ++++++++++++ projects/models.py | 118 +++++++++ projects/serializers.py | 139 +++++++++- projects/urls.py | 44 ++++ projects/validators.py | 7 + projects/views.py | 242 +++++++++++++++++- 7 files changed, 756 insertions(+), 6 deletions(-) create mode 100644 projects/migrations/0030_company_resource_projectcompany_project_companies_and_more.py diff --git a/projects/admin.py b/projects/admin.py index d3a293b7..6f5e8e75 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -3,12 +3,15 @@ from projects.models import ( Achievement, Collaborator, + Company, DefaultProjectAvatar, DefaultProjectCover, Project, + ProjectCompany, ProjectGoal, ProjectLink, ProjectNews, + Resource, ) @@ -20,6 +23,31 @@ class ProjectGoalInline(admin.TabularInline): autocomplete_fields = ("responsible",) +class ProjectCompanyInline(admin.TabularInline): + model = ProjectCompany + extra = 1 + autocomplete_fields = ("company", "decision_maker") + fields = ("company", "contribution", "decision_maker") + verbose_name = "Партнёр проекта" + verbose_name_plural = "Партнёры проекта" + + +class ResourceInline(admin.StackedInline): + model = Resource + extra = 0 + fields = ("type", "description", "partner_company") + show_change_link = True + verbose_name = "Ресурс" + verbose_name_plural = "Ресурсы" + + def get_formset(self, request, obj=None, **kwargs): + formset = super().get_formset(request, obj, **kwargs) + if obj is not None: + qs = obj.companies.all() + formset.form.base_fields["partner_company"].queryset = qs + return formset + + @admin.register(Project) class ProjectAdmin(admin.ModelAdmin): list_display = ( @@ -85,7 +113,28 @@ class ProjectAdmin(admin.ModelAdmin): ), ) readonly_fields = ("datetime_created", "datetime_updated") - inlines = [ProjectGoalInline] + inlines = [ProjectGoalInline, ProjectCompanyInline, ResourceInline] + + +@admin.register(Company) +class CompanyAdmin(admin.ModelAdmin): + list_display = ("id", "name", "inn") + list_display_links = ("id", "name") + search_fields = ("name", "inn") + list_filter = () + ordering = ("name",) + readonly_fields = () + fieldsets = ( + ( + "Компания", + { + "fields": ( + "name", + "inn", + ) + }, + ), + ) @admin.register(ProjectGoal) diff --git a/projects/migrations/0030_company_resource_projectcompany_project_companies_and_more.py b/projects/migrations/0030_company_resource_projectcompany_project_companies_and_more.py new file mode 100644 index 00000000..94a0210e --- /dev/null +++ b/projects/migrations/0030_company_resource_projectcompany_project_companies_and_more.py @@ -0,0 +1,161 @@ +# Generated by Django 4.2.24 on 2025-10-06 09:13 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("projects", "0029_projectgoal"), + ] + + operations = [ + migrations.CreateModel( + name="Company", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "inn", + models.CharField( + max_length=12, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message="ИНН должен содержать 10 или 12 цифр.", + regex="^\\d{10}(\\d{2})?$", + ) + ], + ), + ), + ], + options={ + "verbose_name": "Компания", + "verbose_name_plural": "Компании", + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="Resource", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.CharField( + choices=[ + ("infrastructure", "Инфраструктурный"), + ("staff", "Кадровый"), + ("financial", "Финансовый"), + ("information", "Информационный"), + ], + max_length=32, + ), + ), + ("description", models.TextField()), + ( + "partner_company", + models.ForeignKey( + blank=True, + help_text="Если не указано — ресурс в поиске партнёра.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="resources", + to="projects.company", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="resources", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "Ресурс", + "verbose_name_plural": "Ресурсы", + "ordering": ["project", "type", "id"], + }, + ), + migrations.CreateModel( + name="ProjectCompany", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("contribution", models.TextField(blank=True)), + ( + "company", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_links", + to="projects.company", + ), + ), + ( + "decision_maker", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="partner_decisions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_companies", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "Связь проекта и компании", + "verbose_name_plural": "Связи проекта и компании", + }, + ), + migrations.AddField( + model_name="project", + name="companies", + field=models.ManyToManyField( + related_name="projects", + through="projects.ProjectCompany", + to="projects.company", + ), + ), + migrations.AddConstraint( + model_name="projectcompany", + constraint=models.UniqueConstraint( + fields=("project", "company"), name="uq_project_company_unique_pair" + ), + ), + ] diff --git a/projects/models.py b/projects/models.py index bd1f0df2..b36cd38e 100644 --- a/projects/models.py +++ b/projects/models.py @@ -16,6 +16,7 @@ from files.models import UserFile from industries.models import Industry from projects.managers import AchievementManager, CollaboratorManager, ProjectManager +from projects.validators import inn_validator from users.models import CustomUser User = get_user_model() @@ -170,6 +171,12 @@ class Project(models.Model): User, verbose_name="Подписчики", related_name="subscribed_projects" ) + companies = models.ManyToManyField( + "Company", + through="ProjectCompany", + related_name="projects", + ) + datetime_created = models.DateTimeField( verbose_name="Дата создания", null=False, auto_now_add=True ) @@ -426,3 +433,114 @@ def __str__(self) -> str: class Meta: verbose_name = "Цель" verbose_name_plural = "Цели" + + +class Company(models.Model): + name = models.CharField(max_length=255) + inn = models.CharField(max_length=12, unique=True, validators=[inn_validator]) + + class Meta: + verbose_name = "Компания" + verbose_name_plural = "Компании" + ordering = ["name"] + + def __str__(self): + return f"{self.name} ({self.inn})" + + +class ProjectCompany(models.Model): + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="project_companies", + ) + company = models.ForeignKey( + Company, + on_delete=models.CASCADE, + related_name="project_links", + ) + contribution = models.TextField(blank=True) + decision_maker = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="partner_decisions", + ) + + class Meta: + verbose_name = "Связь проекта и компании" + verbose_name_plural = "Связи проекта и компании" + constraints = [ + models.UniqueConstraint( + fields=["project", "company"], + name="uq_project_company_unique_pair", + ), + ] + + def __str__(self): + return f"{self.project} - {self.company}" + + +class Resource(models.Model): + class ResourceType(models.TextChoices): + INFRASTRUCTURE = "infrastructure", "Инфраструктурный" + STAFF = "staff", "Кадровый" + FINANCIAL = "financial", "Финансовый" + INFORMATION = "information", "Информационный" + + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="resources", + ) + type = models.CharField( + max_length=32, + choices=ResourceType.choices, + ) + description = models.TextField() + + partner_company = models.ForeignKey( + Company, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="resources", + help_text="Если не указано — ресурс в поиске партнёра.", + ) + + class Meta: + verbose_name = "Ресурс" + verbose_name_plural = "Ресурсы" + ordering = ["project", "type", "id"] + + def __str__(self): + base = f"{self.get_type_display()} ресурс для {self.project}" + return f"{base} — {self.partner_display}" + + @property + def partner_display(self): + return ( + self.partner_company.name + if self.partner_company + else "в поиске партнёра для данного ресурса" + ) + + def clean(self): + """ + Проверяет, что выбранная partner_company действительно является партнёром проекта. + """ + super().clean() + if self.partner_company: + exists = ProjectCompany.objects.filter( + project=self.project, company=self.partner_company + ).exists() + if not exists: + raise ValidationError( + { + "partner_company": ( + "Эта компания не является партнёром данного проекта. " + "Сначала добавьте её в партнёры проекта." + ) + } + ) diff --git a/projects/serializers.py b/projects/serializers.py index 23894af3..a80c192c 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from django.core.cache import cache +from django.db import transaction from rest_framework import serializers from core.serializers import SkillToObjectSerializer @@ -19,7 +20,16 @@ PartnerProgramFieldSerializer, PartnerProgramFieldValueSerializer, ) -from projects.models import Achievement, Collaborator, Project, ProjectGoal, ProjectNews +from projects.models import ( + Achievement, + Collaborator, + Company, + Project, + ProjectCompany, + ProjectGoal, + ProjectNews, + Resource, +) from projects.validators import validate_project from vacancy.serializers import ProjectVacancyListSerializer @@ -494,9 +504,7 @@ def _validate_text(self, field, value, attrs): ) else: if value is not None and not isinstance(value, str): - raise serializers.ValidationError( - "Ожидается строка для текстового поля." - ) + raise serializers.ValidationError("Ожидается строка для текстового поля.") def _validate_checkbox(self, field, value, attrs): if field.is_required and value in (None, ""): @@ -575,3 +583,126 @@ def _is_valid_url(self, url: str) -> bool: return parsed.scheme in ("http", "https") and bool(parsed.netloc) except Exception: return False + + +class CompanySerializer(serializers.ModelSerializer): + class Meta: + model = Company + fields = ("id", "name", "inn") + read_only_fields = ("id",) + + +class ProjectCompanySerializer(serializers.ModelSerializer): + company = CompanySerializer() + project = serializers.PrimaryKeyRelatedField(read_only=True) + decision_maker = serializers.PrimaryKeyRelatedField(read_only=True) + + class Meta: + model = ProjectCompany + fields = ("id", "project", "company", "contribution", "decision_maker") + + +class ResourceSerializer(serializers.ModelSerializer): + project_id = serializers.PrimaryKeyRelatedField( + source="project", queryset=Project.objects.all(), write_only=True + ) + + class Meta: + model = Resource + fields = ( + "id", + "project_id", + "project", + "type", + "description", + "partner_company", + ) + read_only_fields = ("id", "project") + + def validate(self, attrs): + project = attrs.get("project", getattr(self.instance, "project", None)) + partner_company = attrs.get( + "partner_company", getattr(self.instance, "partner_company", None) + ) + if project and partner_company: + exists = ProjectCompany.objects.filter( + project=project, company=partner_company + ).exists() + if not exists: + raise serializers.ValidationError( + { + "partner_company": "Эта компания не является партнёром данного проекта." + } + ) + return attrs + + def create(self, validated_data): + obj = Resource(**validated_data) + obj.full_clean() + obj.save() + return obj + + def update(self, instance, validated_data): + for key, value in validated_data.items(): + setattr(instance, key, value) + instance.full_clean() + instance.save() + return instance + + +class ProjectCompanyUpsertSerializer(serializers.Serializer): + name = serializers.CharField(max_length=255) + inn = serializers.RegexField(regex=r"^\d{10}(\d{2})?$") + + contribution = serializers.CharField(allow_blank=True, required=False, default="") + decision_maker = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), allow_null=True, required=False, default=None + ) + + def validate(self, attrs): + return attrs + + @transaction.atomic + def create(self, validated_data): + project = self.context["project"] + name = validated_data["name"].strip() + inn = validated_data["inn"] + contribution = validated_data.get("contribution", "") + decision_maker = validated_data.get("decision_maker", None) + + company, _ = Company.objects.get_or_create( + inn=inn, + defaults={"name": name}, + ) + + link, created = ProjectCompany.objects.get_or_create( + project=project, + company=company, + defaults={"contribution": contribution, "decision_maker": decision_maker}, + ) + if not created: + updated = False + if "contribution" in self.initial_data: + link.contribution = contribution + updated = True + if "decision_maker" in self.initial_data: + link.decision_maker = decision_maker + updated = True + if updated: + link.save() + + return link + + def to_representation(self, instance: ProjectCompany): + return ProjectCompanySerializer(instance).data + + +class ProjectCompanyUpdateSerializer(serializers.ModelSerializer): + contribution = serializers.CharField(allow_blank=True, required=False) + decision_maker = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), allow_null=True, required=False + ) + + class Meta: + model = ProjectCompany + fields = ("contribution", "decision_maker") diff --git a/projects/urls.py b/projects/urls.py index d0ddf8cd..b9810e61 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -9,6 +9,9 @@ GoalViewSet, LeaveProject, ProjectCollaborators, + ProjectCompaniesListView, + ProjectCompanyDetailView, + ProjectCompanyUpsertView, ProjectCountView, ProjectDetail, ProjectList, @@ -17,6 +20,7 @@ ProjectSubscribers, ProjectUnsubscribe, ProjectVacancyResponses, + ResourceViewSet, SetLikeOnProject, SwitchLeaderRole, ) @@ -37,6 +41,21 @@ "delete": "destroy", } ) +project_resource_list = ResourceViewSet.as_view( + { + "get": "list", + "post": "create", + } +) + +project_resource_detail = ResourceViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } +) urlpatterns = [ path("", ProjectList.as_view()), path("/like/", SetLikeOnProject.as_view()), @@ -45,6 +64,31 @@ path("/unsubscribe/", ProjectUnsubscribe.as_view()), path("/subscribers/", ProjectSubscribers.as_view()), path("/goals/", project_goal_list, name="project-goals"), + path( + "/resources/", + project_resource_list, + name="project-resources", + ), + path( + "/resources//", + project_resource_detail, + name="project-resource-detail", + ), + path( + "/companies/", + ProjectCompanyUpsertView.as_view(), + name="project-company-upsert", + ), + path( + "/companies//", + ProjectCompanyDetailView.as_view(), + name="project-company-detail", + ), + path( + "/companies/list/", + ProjectCompaniesListView.as_view(), + name="project-companies-list", + ), path( "/goals//", project_goal_detail, diff --git a/projects/validators.py b/projects/validators.py index f14e813c..98dd75e6 100644 --- a/projects/validators.py +++ b/projects/validators.py @@ -1,3 +1,4 @@ +from django.core.validators import RegexValidator from rest_framework.serializers import ValidationError @@ -11,3 +12,9 @@ def validate_project(data): if error: raise ValidationError(error) return data + + +inn_validator = RegexValidator( + regex=r"^\d{10}(\d{2})?$", + message="ИНН должен содержать 10 или 12 цифр.", +) diff --git a/projects/views.py b/projects/views.py index 4f5c6475..78d45861 100644 --- a/projects/views.py +++ b/projects/views.py @@ -11,6 +11,7 @@ from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, permissions, status, viewsets from rest_framework.exceptions import NotFound +from rest_framework.generics import ListAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -30,7 +31,16 @@ get_recommended_users, update_partner_program, ) -from projects.models import Achievement, Collaborator, Project, ProjectGoal, ProjectNews +from projects.models import ( + Achievement, + Collaborator, + Company, + Project, + ProjectCompany, + ProjectGoal, + ProjectNews, + Resource, +) from projects.pagination import ProjectNewsPagination, ProjectsPagination from projects.permissions import ( CanBindProjectToProgram, @@ -44,8 +54,12 @@ from projects.serializers import ( AchievementDetailSerializer, AchievementListSerializer, + CompanySerializer, EmptySerializer, ProjectCollaboratorSerializer, + ProjectCompanySerializer, + ProjectCompanyUpdateSerializer, + ProjectCompanyUpsertSerializer, ProjectDetailSerializer, ProjectDuplicateRequestSerializer, ProjectGoalSerializer, @@ -53,6 +67,7 @@ ProjectNewsDetailSerializer, ProjectNewsListSerializer, ProjectSubscribersListSerializer, + ResourceSerializer, ) from users.models import LikesOnProject from users.serializers import UserListSerializer @@ -754,3 +769,228 @@ def partial_update(self, request, *args, **kwargs): def perform_update(self, serializer): serializer.save(project=self.get_object().project) + + +class CompanyViewSet(viewsets.ModelViewSet): + queryset = Company.objects.all().order_by("name") + serializer_class = CompanySerializer + permission_classes = (IsProjectLeaderOrReadOnly,) + filterset_fields = ("inn",) + search_fields = ("name", "inn") + + +class ResourceViewSet(viewsets.ModelViewSet): + queryset = Resource.objects.select_related("project", "partner_company").all() + serializer_class = ResourceSerializer + permission_classes = (IsProjectLeaderOrReadOnly,) + filterset_fields = ("type", "project", "partner_company") + search_fields = ("description", "project__name", "partner_company__name") + + def get_queryset(self): + project_pk = self.kwargs.get("project_pk") + queryset = super().get_queryset() + if project_pk is not None: + queryset = queryset.filter(project_id=project_pk) + return queryset + + def perform_create(self, serializer): + project_pk = self.kwargs.get("project_pk") + serializer.save(project_id=project_pk) + + +class ProjectCompanyUpsertView(APIView): + """ + POST /projects//companies/ + Тело: { name, inn, contribution?, decision_maker? } + + Логика: + - если компания с таким inn существует — связываем с проектом (create/get); + - если нет — создаём компанию и тут же связываем. + """ + + permission_classes = (IsProjectLeaderOrReadOnly,) + + @swagger_auto_schema( + request_body=ProjectCompanyUpsertSerializer, + responses={201: ProjectCompanySerializer}, + operation_summary="Создать или привязать компанию к проекту (upsert)", + operation_description="Если компания с таким ИНН уже есть — создаёт/обновляет связь. Если нет — создаёт.", + tags=["Projects • Companies"], + ) + def post(self, request, project_id: int): + try: + project = Project.objects.get(pk=project_id) + except Project.DoesNotExist: + return Response( + {"detail": "Проект не найден."}, status=status.HTTP_404_NOT_FOUND + ) + + serializer = ProjectCompanyUpsertSerializer( + data=request.data, + context={"project": project, "request": request}, + ) + serializer.is_valid(raise_exception=True) + link = serializer.save() + return Response( + serializer.to_representation(link), status=status.HTTP_201_CREATED + ) + + +class ProjectCompaniesListView(ListAPIView): + """ + GET /projects//companies/ + Возвращает список связей (партнёров) проекта с данными компании. + """ + + serializer_class = ProjectCompanySerializer + permission_classes = (IsProjectLeaderOrReadOnly,) + + @swagger_auto_schema( + operation_summary="Список партнёров проекта", + operation_description="Возвращает связи ProjectCompany с вложенными данными компании для указанного проекта.", + responses={200: ProjectCompanySerializer(many=True)}, + tags=["Projects • Companies"], + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + project_id = self.kwargs["project_id"] + return ( + ProjectCompany.objects.select_related("company", "project") + .filter(project_id=project_id) + .order_by("company__name") + ) + + +class ProjectCompanyDetailView(APIView): + """ + PATCH - частично обновляет вклад/ответственного в связи ProjectCompany + DELETE - удаляет только связь; Company остаётся в БД + """ + + permission_classes = (IsProjectLeaderOrReadOnly,) + project_id_param = openapi.Parameter( + "project_id", + openapi.IN_PATH, + description="ID проекта", + type=openapi.TYPE_INTEGER, + required=True, + ) + company_id_param = openapi.Parameter( + "company_id", + openapi.IN_PATH, + description="ID компании", + type=openapi.TYPE_INTEGER, + required=True, + ) + + def _get_link_or_404(self, project_id: int, company_id: int): + try: + project = Project.objects.get(pk=project_id) + except Project.DoesNotExist: + return ( + None, + None, + Response( + {"detail": "Проект не найден."}, status=status.HTTP_404_NOT_FOUND + ), + ) + + try: + company = Company.objects.get(pk=company_id) + except Company.DoesNotExist: + return ( + project, + None, + Response( + {"detail": "Компания не найдена."}, status=status.HTTP_404_NOT_FOUND + ), + ) + + try: + link = ProjectCompany.objects.get(project=project, company=company) + except ProjectCompany.DoesNotExist: + return ( + project, + company, + Response( + {"detail": "Связь проект↔компания не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ), + ) + + return project, company, link + + @swagger_auto_schema( + operation_summary="Обновить вклад и/или ответственного компании в проекте", + operation_description=( + "Позволяет изменить поля связи `ProjectCompany`, такие как `contribution` " + "и `decision_maker`. Компания остаётся без изменений." + ), + manual_parameters=[project_id_param, company_id_param], + request_body=ProjectCompanyUpdateSerializer, + responses={ + 200: ProjectCompanySerializer, + 403: "Недостаточно прав", + 404: "Проект, компания или связь не найдены", + }, + tags=["Projects • Companies"], + ) + def patch(self, request, project_id: int, company_id: int): + project, company, link_or_resp = self._get_link_or_404(project_id, company_id) + if isinstance(link_or_resp, Response): + return link_or_resp + link = link_or_resp + + perm_resp = self._check_permissions(request, project) + if perm_resp: + return perm_resp + + serializer = ProjectCompanyUpdateSerializer( + link, data=request.data, partial=True, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response( + { + "id": link.id, + "project": link.project_id, + "company": { + "id": link.company_id, + "name": link.company.name, + "inn": link.company.inn, + }, + "contribution": link.contribution, + "decision_maker": link.decision_maker_id, + }, + status=status.HTTP_200_OK, + ) + + @swagger_auto_schema( + operation_summary="Удалить связь проекта с компанией", + operation_description=( + "Удаляет запись `ProjectCompany`, связывающую проект и компанию. " + "Сама компания при этом остаётся в базе данных." + ), + manual_parameters=[project_id_param, company_id_param], + responses={ + 204: "Связь успешно удалена", + 403: "Недостаточно прав", + 404: "Проект, компания или связь не найдены", + }, + tags=["Projects • Companies"], + ) + def delete(self, request, project_id: int, company_id: int): + project, company, link_or_resp = self._get_link_or_404(project_id, company_id) + if isinstance(link_or_resp, Response): + return link_or_resp + link = link_or_resp + + perm_resp = self._check_permissions(request, project) + if perm_resp: + return perm_resp + + link.delete() + return Response(status=status.HTTP_204_NO_CONTENT)