diff --git a/core/serializers.py b/core/serializers.py index 6fff7685..4a0f8bfe 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -3,6 +3,10 @@ from .models import SkillToObject, SkillCategory, Skill +class EmptySerializer(serializers.Serializer): + pass + + class SetLikedSerializer(serializers.Serializer): is_liked = serializers.BooleanField() diff --git a/docker-compose.dev-ci.yml b/docker-compose.dev-ci.yml index 09f7188a..d83371f2 100644 --- a/docker-compose.dev-ci.yml +++ b/docker-compose.dev-ci.yml @@ -13,8 +13,8 @@ services: - .env environment: HOST: 0.0.0.0 - expose: - - 8000 + ports: + - "127.0.0.1:8000:8000" grafana: image: grafana/grafana:latest @@ -37,13 +37,13 @@ services: - prom-data:/prometheus - ./prometheus:/etc/prometheus - nginx: - restart: unless-stopped - build: ./nginx - depends_on: - - web - ports: - - 8000:80 + #nginx: + # restart: unless-stopped + # build: ./nginx + # depends_on: + # - web + # ports: + # - 8000:80 loki: image: grafana/loki:2.9.0 diff --git a/feed/views.py b/feed/views.py index 01c4450e..fab314df 100644 --- a/feed/views.py +++ b/feed/views.py @@ -1,18 +1,19 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models import QuerySet, Q +from django.db.models import Q, QuerySet from rest_framework.generics import CreateAPIView from rest_framework.response import Response from rest_framework.views import APIView +from core.serializers import EmptySerializer from feed.pagination import FeedPagination from feed.services import get_liked_news - from news.models import News -from .serializers import NewsFeedListSerializer -from projects.models import Project from partner_programs.models import PartnerProgramUserProfile +from projects.models import Project from vacancy.models import Vacancy +from .serializers import NewsFeedListSerializer + class NewSimpleFeed(APIView): serializator_class = NewsFeedListSerializer @@ -29,11 +30,9 @@ def _get_filter_data(self) -> list[str]: def _get_excluded_projects_ids(self) -> list[int]: """IDs for exclude projects which in Partner Program.""" - excluded_projects = ( - PartnerProgramUserProfile.objects - .values_list("project_id", flat=True) - .exclude(project_id__isnull=True) - ) + excluded_projects = PartnerProgramUserProfile.objects.values_list( + "project_id", flat=True + ).exclude(project_id__isnull=True) return excluded_projects def get_queryset(self) -> QuerySet[News]: @@ -80,6 +79,8 @@ def get(self, *args, **kwargs): class DevScript(CreateAPIView): + serializer_class = EmptySerializer + def create(self, request): content_type_project = ContentType.objects.filter(model="project").first() for project in Project.objects.filter(draft=False): diff --git a/invites/serializers.py b/invites/serializers.py index 1ea64c59..1152ed73 100644 --- a/invites/serializers.py +++ b/invites/serializers.py @@ -1,6 +1,8 @@ +from django.apps import apps from rest_framework import serializers from invites.models import Invite +from projects.models import Collaborator from projects.serializers import ProjectListSerializer from users.serializers import UserDetailSerializer @@ -18,6 +20,48 @@ class Meta: "is_accepted", ] + def validate(self, attrs): + project = attrs["project"] + user = attrs["user"] + + if project.leader_id == user.id: + raise serializers.ValidationError( + {"user": "Пользователь уже является лидером проекта."} + ) + + if Collaborator.objects.filter(project=project, user=user).exists(): + raise serializers.ValidationError( + {"user": "Пользователь уже состоит в проекте."} + ) + + if Invite.objects.filter( + project=project, user=user, is_accepted__isnull=True + ).exists(): + raise serializers.ValidationError( + {"user": "У пользователя уже есть активное приглашение в этот проект."} + ) + + link = project.program_links.select_related("partner_program").first() + if link: + PartnerProgramUserProfile = apps.get_model( + "partner_programs", "PartnerProgramUserProfile" + ) + is_participant = PartnerProgramUserProfile.objects.filter( + user_id=user.id, + partner_program_id=link.partner_program_id, + ).exists() + if not is_participant: + raise serializers.ValidationError( + { + "user": ( + "Нельзя пригласить пользователя: проект относится к программе, " + "а пользователь не является её участником." + ) + } + ) + + return attrs + class InviteDetailSerializer(serializers.ModelSerializer[Invite]): user = UserDetailSerializer(many=False, read_only=True) diff --git a/invites/tests.py b/invites/tests.py index dcabc897..613fd603 100644 --- a/invites/tests.py +++ b/invites/tests.py @@ -36,7 +36,6 @@ def setUp(self) -> None: "name": "Test", "description": "Test", "industry": Industry.objects.create(name="Test").id, - "step": 1, "draft": False, } diff --git a/news/mapping.py b/news/mapping.py index cf736be4..9e1f90e0 100644 --- a/news/mapping.py +++ b/news/mapping.py @@ -14,7 +14,7 @@ class NewsMapping: @classmethod def get_name(cls, content_object) -> str: if isinstance(content_object, User): - f"{content_object.first_name} {content_object.last_name}" + return f"{content_object.first_name} {content_object.last_name}" if isinstance(content_object, Project) or isinstance( content_object, PartnerProgram ): diff --git a/partner_programs/admin.py b/partner_programs/admin.py index a9612592..1e89666a 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -2,6 +2,7 @@ import urllib.parse import tablib +from django import forms from django.contrib import admin from django.db.models import QuerySet from django.http import HttpRequest, HttpResponse @@ -37,7 +38,19 @@ class PartnerProgramFieldInline(admin.TabularInline): @admin.register(PartnerProgram) class PartnerProgramAdmin(admin.ModelAdmin): + class PartnerProgramAdminForm(forms.ModelForm): + class Meta: + model = PartnerProgram + fields = "__all__" + widgets = { + "name": forms.TextInput(attrs={"size": 80}), + "tag": forms.TextInput(attrs={"size": 80}), + "description": forms.Textarea(attrs={"rows": 4, "cols": 82}), + "city": forms.TextInput(attrs={"size": 80}), + } + inlines = [PartnerProgramMaterialInline, PartnerProgramFieldInline] + form = PartnerProgramAdminForm list_display = ("id", "name", "tag", "city", "datetime_created") list_display_links = ( "id", @@ -53,8 +66,39 @@ class PartnerProgramAdmin(admin.ModelAdmin): ) list_filter = ("city",) - filter_horizontal = ("users", "managers") + filter_horizontal = ("managers",) date_hierarchy = "datetime_started" + readonly_fields = ("datetime_created", "datetime_updated") + fieldsets = ( + ( + None, + { + "fields": ( + "name", + "tag", + "description", + "city", + "is_competitive", + "projects_availability", + "draft", + ( + "datetime_started", + "datetime_registration_ends", + "datetime_finished", + ), + ( + "image_address", + "cover_image_address", + "advertisement_image_address", + ), + ("presentation_address", "registration_link"), + "data_schema", + ) + }, + ), + ("Менеджеры программы", {"fields": ("managers",)}), + ("Служебная информация", {"fields": ("datetime_created", "datetime_updated")}), + ) def get_queryset(self, request: HttpRequest) -> QuerySet[PartnerProgram]: qs = super().get_queryset(request) diff --git a/partner_programs/migrations/0012_partnerprogram_registration_link.py b/partner_programs/migrations/0012_partnerprogram_registration_link.py new file mode 100644 index 00000000..d229008c --- /dev/null +++ b/partner_programs/migrations/0012_partnerprogram_registration_link.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.24 on 2025-10-31 10:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partner_programs', '0011_partnerprogram_is_competitive_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='partnerprogram', + name='registration_link', + field=models.URLField(blank=True, help_text='Адрес страницы регистрации (например, на Tilda)', null=True, verbose_name='Ссылка на регистрацию'), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 34eb2e3a..096dbca2 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -73,6 +73,12 @@ class PartnerProgram(models.Model): blank=True, verbose_name="Ссылка на презентацию", ) + registration_link = models.URLField( + null=True, + blank=True, + verbose_name="Ссылка на регистрацию", + help_text="Адрес страницы регистрации (например, на Tilda)", + ) data_schema = models.JSONField( verbose_name="Схема данных в формате JSON", help_text="Ключи - имена полей, значения - тип поля ввода", diff --git a/partner_programs/serializers.py b/partner_programs/serializers.py index 43e2806f..21083e1f 100644 --- a/partner_programs/serializers.py +++ b/partner_programs/serializers.py @@ -45,6 +45,7 @@ class Meta: "name", "image_address", "short_description", + "registration_link", "datetime_registration_ends", "datetime_started", "datetime_finished", @@ -80,9 +81,7 @@ class PartnerProgramForMemberSerializer(PartnerProgramBaseSerializerMixin): views_count = serializers.SerializerMethodField(method_name="count_views") links = serializers.SerializerMethodField(method_name="get_links") - is_user_manager = serializers.SerializerMethodField( - method_name="get_is_user_manager" - ) + is_user_manager = serializers.SerializerMethodField(method_name="get_is_user_manager") def count_views(self, program): return get_views_count(program) @@ -112,6 +111,7 @@ class Meta: "image_address", "cover_image_address", "presentation_address", + "registration_link", "views_count", "datetime_registration_ends", "is_user_manager", @@ -133,6 +133,7 @@ class Meta: "cover_image_address", "advertisement_image_address", "presentation_address", + "registration_link", "datetime_registration_ends", "is_user_manager", ) diff --git a/partner_programs/views.py b/partner_programs/views.py index 2783bb14..3f99cd77 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -19,7 +19,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from core.serializers import SetLikedSerializer, SetViewedSerializer +from core.serializers import EmptySerializer, SetLikedSerializer, SetViewedSerializer from core.services import add_view, set_like from partner_programs.helpers import date_to_iso from partner_programs.models import ( @@ -69,6 +69,7 @@ class PartnerProgramList(generics.ListCreateAPIView): class PartnerProgramDetail(generics.RetrieveAPIView): queryset = PartnerProgram.objects.prefetch_related("materials", "managers").all() permission_classes = [permissions.IsAuthenticatedOrReadOnly] + serializer_class = PartnerProgramForUnregisteredUserSerializer def get(self, request, *args, **kwargs): program = self.get_object() @@ -320,7 +321,7 @@ def put(self, request, project_id, *args, **kwargs): class PartnerProgramProjectSubmitView(GenericAPIView): permission_classes = [IsAuthenticated, IsProjectLeader] - serializer_class = None + serializer_class = EmptySerializer queryset = PartnerProgramProject.objects.all() @swagger_auto_schema( @@ -375,6 +376,7 @@ class ProgramProjectFilterAPIView(GenericAPIView): serializer_class = ProgramProjectFilterRequestSerializer permission_classes = [permissions.IsAuthenticated] pagination_class = PartnerProgramPagination + queryset = PartnerProgram.objects.none() def post(self, request, pk): serializer = self.get_serializer(data=request.data) @@ -445,6 +447,9 @@ class PartnerProgramProjectsAPIView(generics.ListAPIView): pagination_class = PartnerProgramPagination def get_queryset(self): + if "pk" not in self.kwargs: + return Project.objects.none() + program = get_object_or_404(PartnerProgram, pk=self.kwargs["pk"]) return Project.objects.filter(program_links__partner_program=program).distinct() diff --git a/procollab/celery.py b/procollab/celery.py index f56806c8..882377a2 100644 --- a/procollab/celery.py +++ b/procollab/celery.py @@ -1,22 +1,17 @@ import os from celery import Celery -import django -# from celery.schedules import crontab from celery.schedules import crontab os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings") -django.setup() -app = Celery("procollab") +app = Celery("procollab") app.config_from_object("django.conf:settings", namespace="CELERY") - app.autodiscover_tasks() app.conf.task_serializer = "json" - app.conf.beat_schedule = { "outdate_vacancy": { "task": "vacancy.tasks.email_notificate_vacancy_outdated", diff --git a/projects/admin.py b/projects/admin.py index d7cbce7e..6f5e8e75 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -3,14 +3,51 @@ from projects.models import ( Achievement, Collaborator, + Company, DefaultProjectAvatar, DefaultProjectCover, Project, + ProjectCompany, + ProjectGoal, ProjectLink, ProjectNews, + Resource, ) +class ProjectGoalInline(admin.TabularInline): + model = ProjectGoal + extra = 0 + fields = ("title", "completion_date", "responsible", "is_done") + show_change_link = True + 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 = ( @@ -18,23 +55,13 @@ class ProjectAdmin(admin.ModelAdmin): "name", "draft", "is_company", - "track", - "direction", - ) - list_display_links = ( - "id", - "name", - ) - search_fields = ( - "name", - "track", - ) - list_filter = ( - "draft", - "is_company", - "track", - "direction", + "trl", + "target_audience", + "implementation_deadline", ) + list_display_links = ("id", "name") + search_fields = ("name",) + list_filter = ("draft", "is_company", "trl") fieldsets = ( ( @@ -46,21 +73,20 @@ class ProjectAdmin(admin.ModelAdmin): "leader", "industry", "region", - "step", "draft", "is_company", ) }, ), ( - "Для проектов ПД МосПолитеха", + "Характеристики проекта", { "fields": ( - "track", - "direction", "actuality", - "goal", "problem", + "target_audience", + "trl", + "implementation_deadline", ) }, ), @@ -87,6 +113,49 @@ class ProjectAdmin(admin.ModelAdmin): ), ) readonly_fields = ("datetime_created", "datetime_updated") + 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) +class ProjectGoalAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "project", + "completion_date", + "responsible", + "is_done", + ) + list_filter = ("is_done", "completion_date", "project") + search_fields = ( + "title", + "project__name", + "responsible__username", + "responsible__email", + ) + list_select_related = ("project", "responsible") + autocomplete_fields = ("project", "responsible") @admin.register(ProjectNews) diff --git a/projects/constants.py b/projects/constants.py deleted file mode 100644 index 0dd9684e..00000000 --- a/projects/constants.py +++ /dev/null @@ -1,9 +0,0 @@ -VERBOSE_STEPS = ( - (0, "Идея"), - (1, "Прототип"), - (2, "MVP(Минимально жизнеспособный продукт)"), - (3, "Первые продажи"), - (4, "Масштабирование"), -) - -RECOMMENDATIONS_COUNT = 5 diff --git a/projects/filters.py b/projects/filters.py index 3d1f36c6..9ece8dc4 100644 --- a/projects/filters.py +++ b/projects/filters.py @@ -13,9 +13,9 @@ class ProjectFilter(filters.FilterSet): Adds filtering to DRF list retrieve views Parameters to filter by: - industry (int), step (int), region (str), name__contains (str), + industry (int), region (str), name__contains (str), description__contains (str), collaborator__user__in (List[int]), - datetime_created__gt (datetime.datetime), step (int), any_vacancies (bool), + datetime_created__gt (datetime.datetime), any_vacancies (bool), member_count__gt (int), member_count__lt (int), leader (int), partner_program (int), is_company (bool). @@ -25,7 +25,6 @@ class ProjectFilter(filters.FilterSet): ?datetime_created__gt=25.10.2022 equals to .filter(datetime_created__gt=datetime.datetime(...)) ?collaborator__user__in=1,2 equals to .filter(collaborator__user__in=[1, 2]) - ?step=1 equals to .filter(step=1) ?any_vacancies=true equals to .filter(any_vacancies=True) ?collaborator__count__gt=1 equals to .filter(collaborator__count__gt=1) ?is_company=0/?is_company=false equals .filter(is_company=False) @@ -113,7 +112,6 @@ def filter_by_have_expert_rates(self, queryset, name, value): collaborator__count__lte = filters.NumberFilter( field_name="collaborator", method="filter_collaborator_count_lte" ) - step = filters.NumberFilter(field_name="step") partner_program = filters.NumberFilter( field_name="partner_program", method="filter_by_partner_program" ) @@ -128,10 +126,8 @@ class Meta: model = Project fields = ( "industry", - "step", "region", "leader", - "step", "partner_program", "is_company", ) diff --git a/projects/helpers.py b/projects/helpers.py index 36e7a50b..ad3c02bc 100644 --- a/projects/helpers.py +++ b/projects/helpers.py @@ -7,7 +7,6 @@ from rest_framework.exceptions import ValidationError from partner_programs.models import PartnerProgram, PartnerProgramUserProfile -from projects.constants import RECOMMENDATIONS_COUNT from projects.models import Project, ProjectLink, Achievement from users.models import CustomUser @@ -18,7 +17,7 @@ def get_recommended_users(project: Project) -> list[User]: """ Searches for users by matching their skills and vacancies required_skills """ - + RECOMMENDATIONS_COUNT = 5 all_needed_skills = set() for vacancy in project.vacancies.all(): all_needed_skills.update(set(vacancy.get_required_skills())) diff --git a/projects/managers.py b/projects/managers.py index a0fa0626..bf701459 100644 --- a/projects/managers.py +++ b/projects/managers.py @@ -16,7 +16,7 @@ def get_draft_projects_for_user(self): class ProjectManager(Manager): def get_projects_for_list_view(self): - return self.get_queryset().filter(draft=False) + return self.get_queryset().filter(draft=False).prefetch_related("program_links") def get_user_projects_for_list_view(self): return self.get_queryset().distinct() @@ -29,6 +29,10 @@ def get_projects_for_detail_view(self): "collaborator_set", "vacancies", "links", + "goals__responsible", + "project_companies__company", + "project_companies__decision_maker", + "resources__partner_company", ) .all() ) diff --git a/projects/migrations/0028_remove_project_direction_remove_project_goal_and_more.py b/projects/migrations/0028_remove_project_direction_remove_project_goal_and_more.py new file mode 100644 index 00000000..84e7897f --- /dev/null +++ b/projects/migrations/0028_remove_project_direction_remove_project_goal_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.11 on 2025-09-02 07:59 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0027_alter_defaultprojectcover_datetime_created_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="project", + name="direction", + ), + migrations.RemoveField( + model_name="project", + name="goal", + ), + migrations.RemoveField( + model_name="project", + name="step", + ), + migrations.RemoveField( + model_name="project", + name="track", + ), + migrations.AddField( + model_name="project", + name="implementation_deadline", + field=models.DateField( + blank=True, + help_text="Дата, до которой планируется реализовать проект", + null=True, + verbose_name="Общий срок реализации проекта", + ), + ), + migrations.AddField( + model_name="project", + name="target_audience", + field=models.CharField( + blank=True, + help_text="Описание целевой аудитории проекта (до 500 симв.)", + max_length=500, + null=True, + verbose_name="Целевая аудитория", + ), + ), + migrations.AddField( + model_name="project", + name="trl", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Technology Readiness Level (от 1 до 9)", + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(9), + ], + verbose_name="TRL", + ), + ), + ] diff --git a/projects/migrations/0029_projectgoal.py b/projects/migrations/0029_projectgoal.py new file mode 100644 index 00000000..884dd200 --- /dev/null +++ b/projects/migrations/0029_projectgoal.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.11 on 2025-09-08 06:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("projects", "0028_remove_project_direction_remove_project_goal_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectGoal", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255, verbose_name="Название цели")), + ( + "completion_date", + models.DateField( + blank=True, null=True, verbose_name="Срок реализации цели" + ), + ), + ("is_done", models.BooleanField(default=False, verbose_name="Выполнено")), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="goals", + to="projects.project", + verbose_name="Проект", + ), + ), + ( + "responsible", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="responsible_goals", + to=settings.AUTH_USER_MODEL, + verbose_name="Ответственный", + ), + ), + ], + options={ + "verbose_name": "Цель", + "verbose_name_plural": "Цели", + }, + ), + ] 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 117b65c7..b36cd38e 100644 --- a/projects/models.py +++ b/projects/models.py @@ -1,16 +1,22 @@ from typing import Optional +from django.apps import apps from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericRelation -from django.core.validators import MaxLengthValidator +from django.core.exceptions import ValidationError +from django.core.validators import ( + MaxLengthValidator, + MaxValueValidator, + MinValueValidator, +) from django.db import models from django.db.models import UniqueConstraint from core.models import Like, View from files.models import UserFile from industries.models import Industry -from projects.constants import VERBOSE_STEPS from projects.managers import AchievementManager, CollaboratorManager, ProjectManager +from projects.validators import inn_validator from users.models import CustomUser User = get_user_model() @@ -60,48 +66,36 @@ class Meta: class Project(models.Model): """ - Project model - - Attributes: - name: A CharField name of the project. - description: A TextField description of the project. - region: A CharField region of the project. - step: A PositiveSmallIntegerField which indicates status of the project - according to VERBOSE_STEPS. - industry: A ForeignKey referring to the Industry model. - presentation_address: A URLField presentation URL address. - image_address: A URLField image URL address. - leader: A ForeignKey referring to the User model. - draft: A boolean indicating if Project is a draft. - is_company: A boolean indicating if Project is a company. - cover_image_address: A URLField cover image URL address. - cover: A ForeignKey referring to the UserFile model, which is the image cover of the project. - datetime_created: A DateTimeField indicating date of creation. - datetime_updated: A DateTimeField indicating date of update. + Модель проекта. + + Атрибуты: + name (CharField): Название проекта. + description (TextField): Подробное описание проекта. + region (CharField): Регион, в котором реализуется проект. + hidden_score (PositiveSmallIntegerField): Скрытый рейтинг проекта, + используется для внутренней сортировки. + actuality (TextField): Актуальность проекта (почему он важен). + target_audience (CharField): Описание целевой аудитории проекта. + implementation_deadline (DateField): Общий срок реализации проекта (дата завершения). + problem (TextField): Проблема, которую решает проект. + trl (PositiveSmallIntegerField): Уровень технологической готовности (Technology Readiness Level) от 1 до 9. + industry (ForeignKey): Ссылка на отрасль (модель Industry). + presentation_address (URLField): Ссылка на презентацию проекта. + image_address (URLField): Ссылка на изображение (аватар проекта). + leader (ForeignKey): Руководитель проекта (пользователь). + draft (BooleanField): Флаг, указывающий, является ли проект черновиком. + is_company (BooleanField): Признак того, что проект представляет компанию. + cover_image_address (URLField): Ссылка на обложку проекта. + cover (ForeignKey): Файл-обложка проекта (устаревшее поле). + subscribers (ManyToManyField): Подписчики проекта. + datetime_created (DateTimeField): Дата создания проекта. + datetime_updated (DateTimeField): Дата последнего изменения проекта. """ name = models.CharField(max_length=256, null=True, blank=True) description = models.TextField(null=True, blank=True) region = models.CharField(max_length=256, null=True, blank=True) - step = models.PositiveSmallIntegerField( - choices=VERBOSE_STEPS, null=True, blank=True - ) hidden_score = models.PositiveSmallIntegerField(default=100) - - track = models.CharField( - max_length=256, - blank=True, - null=True, - verbose_name="Трек", - help_text="Направление/курс, в рамках которого реализуется проект", - ) - direction = models.CharField( - max_length=256, - blank=True, - null=True, - verbose_name="Направление", - help_text="Более общее направление деятельности проекта", - ) actuality = models.TextField( blank=True, null=True, @@ -109,12 +103,25 @@ class Project(models.Model): verbose_name="Актуальность", help_text="Почему проект важен (до 1000 симв.)", ) - goal = models.CharField( + target_audience = models.CharField( max_length=500, blank=True, null=True, - verbose_name="Цель", - help_text="Главная цель проекта (до 500 симв.)", + verbose_name="Целевая аудитория", + help_text="Описание целевой аудитории проекта (до 500 симв.)", + ) + trl = models.PositiveSmallIntegerField( + verbose_name="TRL", + help_text="Technology Readiness Level (от 1 до 9)", + validators=[MinValueValidator(1), MaxValueValidator(9)], + null=True, + blank=True, + ) + implementation_deadline = models.DateField( + verbose_name="Общий срок реализации проекта", + help_text="Дата, до которой планируется реализовать проект", + null=True, + blank=True, ) problem = models.TextField( blank=True, @@ -164,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 ) @@ -306,6 +319,35 @@ class Meta: ) ] + def clean(self): + """ + Если проект привязан к программе, добавлять коллаборатора можно + только если пользователь — участник этой программы. + (Проект привязан максимум к одной программе.) + """ + link = self.project.program_links.select_related("partner_program").first() + if not link: + return + + PartnerProgramUserProfile = apps.get_model( + "partner_programs", + "PartnerProgramUserProfile", + ) + + is_participant = PartnerProgramUserProfile.objects.filter( + user_id=self.user_id, + partner_program_id=link.partner_program_id, + ).exists() + + if not is_participant: + raise ValidationError( + "Пользователь не является участником программы, к которой относится проект." + ) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + class ProjectNews(models.Model): """ @@ -348,3 +390,157 @@ class Meta: verbose_name = "Новость проекта" verbose_name_plural = "Новости проекта" ordering = ["-datetime_created"] + + +class ProjectGoal(models.Model): + """ + Цель проекта (минимальная версия). + """ + + project = models.ForeignKey( + "Project", + on_delete=models.CASCADE, + related_name="goals", + verbose_name="Проект", + ) + + title = models.CharField( + max_length=255, + verbose_name="Название цели", + ) + + completion_date = models.DateField( + null=True, + blank=True, + verbose_name="Срок реализации цели", + ) + + responsible = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="responsible_goals", + verbose_name="Ответственный", + ) + + is_done = models.BooleanField( + default=False, + verbose_name="Выполнено", + ) + + def __str__(self) -> str: + return f"Проект [{self.project_id}] - {self.title}" + + 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/permissions.py b/projects/permissions.py index 4a18a68a..886a6d72 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -1,12 +1,11 @@ -from datetime import timedelta, datetime +from datetime import datetime, timedelta from django.utils import timezone +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.permissions import SAFE_METHODS, BasePermission -from rest_framework.permissions import BasePermission, SAFE_METHODS -from rest_framework.exceptions import PermissionDenied - +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from projects.models import Project -from partner_programs.models import PartnerProgramUserProfile class IsProjectLeaderOrReadOnlyForNonDrafts(BasePermission): @@ -79,6 +78,7 @@ class TimingAfterEndsProgramPermission(BasePermission): for `_SECONDS_AFTER_CANT_EDIT` seconds -> days from the end of the program. If the project is not in program or the request in `SAFE_METHODS` -> allowed. """ + _SECONDS_AFTER_CANT_EDIT: int = 60 * 60 * 24 * 30 # Now 30 days. def has_object_permission(self, request, view, obj) -> bool: @@ -86,22 +86,29 @@ def has_object_permission(self, request, view, obj) -> bool: return True program_profile = ( - PartnerProgramUserProfile.objects - .filter(user=request.user, project=obj) + PartnerProgramUserProfile.objects.filter(user=request.user, project=obj) .select_related("partner_program") .first() ) moscow_time: datetime = timezone.localtime(timezone.now()) if program_profile: - date_from_end_program: timedelta = (moscow_time - program_profile.partner_program.datetime_finished) + date_from_end_program: timedelta = ( + moscow_time - program_profile.partner_program.datetime_finished + ) days_from_end_program: int = date_from_end_program.days seconds_from_end_program: int = date_from_end_program.total_seconds() if 0 <= seconds_from_end_program <= self._SECONDS_AFTER_CANT_EDIT: - raise PermissionDenied(detail=self._prepare_exception_detail(days_from_end_program, program_profile)) + raise PermissionDenied( + detail=self._prepare_exception_detail( + days_from_end_program, program_profile + ) + ) return True - def _prepare_exception_detail(self, days_from_end_program: int, program_profile: PartnerProgramUserProfile): + def _prepare_exception_detail( + self, days_from_end_program: int, program_profile: PartnerProgramUserProfile + ): """ Prepare response body when `PermissionDenied` exception raised: program_name: str -> Program title @@ -112,7 +119,11 @@ def _prepare_exception_detail(self, days_from_end_program: int, program_profile: when_can_edit: datetime = timezone.localtime( datetime_finished + timedelta(seconds=self._SECONDS_AFTER_CANT_EDIT) ) - days_until_resolution: int = int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24) - days_from_end_program - 1 + days_until_resolution: int = ( + int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24) + - days_from_end_program + - 1 + ) return { "program_name": program_profile.partner_program.name, "when_can_edit": when_can_edit, @@ -140,3 +151,62 @@ def has_object_permission(self, request, view, obj): ) or obj.project.leader == request.user: return True return False + + +class IsProjectLeaderOrReadOnly(BasePermission): + """ + Читать могут все (в т.ч. анонимы). + Создавать/изменять/удалять может только лидер проекта. + """ + + message = "Только лидер проекта может создавать, изменять или удалять параметры." + + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + + if not request.user or not request.user.is_authenticated: + return False + + project_pk = view.kwargs.get("project_pk") + project_id = project_pk or view.kwargs.get("project_id") or request.data.get("project") + if not project_id: + return False + + try: + project = Project.objects.only("id", "leader_id").get(pk=project_id) + except Project.DoesNotExist: + return False + + return project.leader_id == request.user.id + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + + return ( + request.user + and request.user.is_authenticated + and obj.project.leader_id == request.user.id + ) + + +class CanBindProjectToProgram(BasePermission): + message = "Привязать проект к программе может только её участник (или менеджер)." + + def has_permission(self, request, view): + program_id = (request.data or {}).get("partner_program_id") + if not program_id: + return True + + try: + program = PartnerProgram.objects.get(pk=program_id) + except PartnerProgram.DoesNotExist: + raise ValidationError({"partner_program_id": "Программа не найдена."}) + + if program.is_manager(request.user): + return True + + return PartnerProgramUserProfile.objects.filter( + user=request.user, partner_program=program + ).exists() diff --git a/projects/serializers.py b/projects/serializers.py index e2d300be..00d10dc9 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,13 +20,26 @@ PartnerProgramFieldSerializer, PartnerProgramFieldValueSerializer, ) -from projects.models import Achievement, Collaborator, Project, ProjectNews +from projects.models import ( + Achievement, + Collaborator, + Company, + Project, + ProjectCompany, + ProjectGoal, + ProjectNews, + Resource, +) from projects.validators import validate_project from vacancy.serializers import ProjectVacancyListSerializer User = get_user_model() +class EmptySerializer(serializers.Serializer): + pass + + class AchievementListSerializer(serializers.ModelSerializer): class Meta: model = Achievement @@ -109,6 +123,110 @@ def get_program_field_values(self, obj): return PartnerProgramFieldValueSerializer(values_qs, many=True).data +class ResponsibleMiniSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ("id", "first_name", "last_name", "avatar") + + +class ProjectGoalBulkListSerializer(serializers.ListSerializer): + """Bulk_create при POST запросе со списком объектов на /projects/{project_pk}/goals/.""" + + def create(self, validated_data): + project = self.context["project"] + objs = [ProjectGoal(project=project, **item) for item in validated_data] + created = ProjectGoal.objects.bulk_create(objs) + return created + + +class ProjectGoalSerializer(serializers.ModelSerializer): + project_id = serializers.IntegerField(read_only=True) + responsible = serializers.PrimaryKeyRelatedField(queryset=User.objects.all()) + responsible_info = ResponsibleMiniSerializer(source="responsible", read_only=True) + + class Meta: + model = ProjectGoal + fields = [ + "id", + "project_id", + "title", + "completion_date", + "responsible", + "responsible_info", + "is_done", + ] + read_only_fields = ["id", "project_id", "responsible_info"] + list_serializer_class = ProjectGoalBulkListSerializer + + +class CompanySerializer(serializers.ModelSerializer): + class Meta: + model = Company + fields = ("id", "name", "inn") + read_only_fields = ("id",) + + +class ProjectCompanySerializer(serializers.ModelSerializer): + company = CompanySerializer() + project_id = serializers.IntegerField(read_only=True) + decision_maker = serializers.PrimaryKeyRelatedField(read_only=True) + + class Meta: + model = ProjectCompany + fields = ("id", "project_id", "company", "contribution", "decision_maker") + + +class ResourceSerializer(serializers.ModelSerializer): + project_id = serializers.IntegerField(read_only=True) + project = serializers.PrimaryKeyRelatedField( + queryset=Project.objects.all(), + write_only=True, + required=False, + ) + + 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 ProjectDetailSerializer(serializers.ModelSerializer): achievements = AchievementListSerializer(many=True, read_only=True) cover = UserFileSerializer(required=False) @@ -116,17 +234,24 @@ class ProjectDetailSerializer(serializers.ModelSerializer): source="collaborator_set", many=True, read_only=True ) vacancies = ProjectVacancyListSerializer(many=True, read_only=True) + goals = ProjectGoalSerializer(many=True, read_only=True) + partners = ProjectCompanySerializer( + source="project_companies", many=True, read_only=True + ) + resources = ResourceSerializer(many=True, read_only=True) short_description = serializers.SerializerMethodField() industry_id = serializers.IntegerField(required=False) views_count = serializers.SerializerMethodField(method_name="count_views") links = serializers.SerializerMethodField() partner_program = serializers.SerializerMethodField() partner_program_tags = serializers.SerializerMethodField() - track = serializers.CharField(required=False, allow_null=True, allow_blank=True) - direction = serializers.CharField(required=False, allow_null=True, allow_blank=True) actuality = serializers.CharField(required=False, allow_null=True, allow_blank=True) - goal = serializers.CharField(required=False, allow_null=True, allow_blank=True) problem = serializers.CharField(required=False, allow_null=True, allow_blank=True) + target_audience = serializers.CharField( + required=False, allow_blank=True, allow_null=True + ) + implementation_deadline = serializers.DateField(required=False, allow_null=True) + trl = serializers.IntegerField(required=False, allow_null=True) def get_partner_program(self, project): try: @@ -170,28 +295,30 @@ class Meta: "description", "short_description", "achievements", + "goals", "links", "region", - "step", "industry", "industry_id", "presentation_address", "image_address", "collaborators", + "partners", "leader", "draft", "is_company", "vacancies", + "resources", "datetime_created", "datetime_updated", "views_count", "cover", "cover_image_address", - "track", - "direction", "actuality", - "goal", "problem", + "target_audience", + "implementation_deadline", + "trl", "partner_program_tags", "partner_program", ] @@ -207,6 +334,7 @@ class Meta: class ProjectListSerializer(serializers.ModelSerializer): views_count = serializers.SerializerMethodField(method_name="count_views") short_description = serializers.SerializerMethodField() + partner_program_id = serializers.SerializerMethodField() @classmethod def count_views(cls, project): @@ -216,6 +344,14 @@ def count_views(cls, project): def get_short_description(cls, project): return project.get_short_description() + @staticmethod + def get_partner_program_id(project): + links_cache = getattr(project, "_prefetched_objects_cache", {}).get( + "program_links" + ) + link = links_cache[0] if links_cache else project.program_links.first() + return link.partner_program_id if link else None + class Meta: model = Project fields = [ @@ -227,9 +363,10 @@ class Meta: "industry", "views_count", "is_company", + "partner_program_id", ] - read_only_fields = ["leader", "views_count", "is_company"] + read_only_fields = ["leader", "views_count", "is_company", "partner_program_id"] def is_valid(self, *, raise_exception=False): return super().is_valid(raise_exception=raise_exception) @@ -386,7 +523,7 @@ def validate(self, data): if project.leader != request.user: raise serializers.ValidationError( - "Только лидер проекта может дублировать его в программу." + {"error": "Только лидер проекта может дублировать его в программу."} ) try: @@ -453,9 +590,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, ""): @@ -534,3 +669,61 @@ def _is_valid_url(self, url: str) -> bool: return parsed.scheme in ("http", "https") and bool(parsed.netloc) except Exception: return False + + +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/tests.py b/projects/tests.py index ff30af9d..c046d699 100644 --- a/projects/tests.py +++ b/projects/tests.py @@ -19,7 +19,6 @@ def setUp(self): "name": "Test", "description": "Test", "industry": Industry.objects.create(name="Test").id, - "step": 1, } def test_project_creation(self): @@ -42,7 +41,6 @@ def test_project_creation_with_wrong_data(self): "name": "T" * 257, "description": "Test", "industry": Industry.objects.create(name="Test").id, - "step": 1, }, ) force_authenticate(request, user=user) diff --git a/projects/urls.py b/projects/urls.py index 636f8ca7..b9810e61 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -6,23 +6,56 @@ AchievementDetail, AchievementList, DuplicateProjectView, + GoalViewSet, LeaveProject, ProjectCollaborators, + ProjectCompaniesListView, + ProjectCompanyDetailView, + ProjectCompanyUpsertView, ProjectCountView, ProjectDetail, ProjectList, ProjectRecommendedUsers, - ProjectSteps, ProjectSubscribe, ProjectSubscribers, ProjectUnsubscribe, ProjectVacancyResponses, + ResourceViewSet, SetLikeOnProject, SwitchLeaderRole, ) app_name = "projects" +project_goal_list = GoalViewSet.as_view( + { + "get": "list", + "post": "create", + } +) +project_goal_detail = GoalViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "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()), @@ -30,6 +63,37 @@ path("/subscribe/", ProjectSubscribe.as_view()), 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, + name="project-goal-detail", + ), path("/news//", NewsDetail.as_view()), path("/news//set_viewed/", NewsDetailSetViewed.as_view()), path("/news//set_liked/", NewsDetailSetLiked.as_view()), @@ -41,16 +105,13 @@ ), path("/", ProjectDetail.as_view()), path("/recommended_users", ProjectRecommendedUsers.as_view()), - path( - "assign-to-program/", DuplicateProjectView.as_view(), name="duplicate-project" - ), + path("assign-to-program/", DuplicateProjectView.as_view(), name="duplicate-project"), path( "/program-fields/", PartnerProgramFieldValueBulkUpdateView.as_view(), name="update_program_fields", ), path("count/", ProjectCountView.as_view()), - path("steps/", ProjectSteps.as_view()), path("achievements/", AchievementList.as_view()), path("achievements//", AchievementDetail.as_view()), path("/responses/", ProjectVacancyResponses.as_view()), 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 bc330f9e..5820f58a 100644 --- a/projects/views.py +++ b/projects/views.py @@ -9,8 +9,9 @@ from django_filters import rest_framework as filters from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from rest_framework import generics, permissions, status +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 @@ -23,7 +24,6 @@ PartnerProgramProject, PartnerProgramUserProfile, ) -from projects.constants import VERBOSE_STEPS from projects.exceptions import CollaboratorDoesNotExist from projects.filters import ProjectFilter from projects.helpers import ( @@ -31,25 +31,43 @@ get_recommended_users, update_partner_program, ) -from projects.models import Achievement, Collaborator, Project, ProjectNews +from projects.models import ( + Achievement, + Collaborator, + Company, + Project, + ProjectCompany, + ProjectGoal, + ProjectNews, + Resource, +) from projects.pagination import ProjectNewsPagination, ProjectsPagination from projects.permissions import ( + CanBindProjectToProgram, HasInvolvementInProjectOrReadOnly, IsNewsAuthorIsProjectLeaderOrReadOnly, IsProjectLeader, + IsProjectLeaderOrReadOnly, IsProjectLeaderOrReadOnlyForNonDrafts, TimingAfterEndsProgramPermission, ) +from core.serializers import EmptySerializer from projects.serializers import ( AchievementDetailSerializer, AchievementListSerializer, + CompanySerializer, ProjectCollaboratorSerializer, + ProjectCompanySerializer, + ProjectCompanyUpdateSerializer, + ProjectCompanyUpsertSerializer, ProjectDetailSerializer, ProjectDuplicateRequestSerializer, + ProjectGoalSerializer, ProjectListSerializer, ProjectNewsDetailSerializer, ProjectNewsListSerializer, ProjectSubscribersListSerializer, + ResourceSerializer, ) from users.models import LikesOnProject from users.serializers import UserListSerializer @@ -88,9 +106,7 @@ def create(self, request, *args, **kwargs): try: partner_program_id = request.data.get("partner_program_id") - update_partner_program( - partner_program_id, request.user, serializer.instance - ) + update_partner_program(partner_program_id, request.user, serializer.instance) except PartnerProgram.DoesNotExist: return Response( {"detail": "Partner program with this id does not exist"}, @@ -103,9 +119,7 @@ def create(self, request, *args, **kwargs): ) headers = self.get_success_headers(serializer.data) - return Response( - serializer.data, status=status.HTTP_201_CREATED, headers=headers - ) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def post(self, request, *args, **kwargs): """ @@ -121,7 +135,6 @@ def post(self, request, *args, **kwargs): [name] - название проекта [description] - описание проекта [industry] - id отрасли - [step] - этап проекта [image_address] - адрес изображения [presentation_address] - адрес презентации [short_description] - краткое описание проекта @@ -240,9 +253,7 @@ def get(self, request): { "all": self.get_queryset().filter(draft=False).count(), "my": self.get_queryset() - .filter( - Q(leader_id=request.user.id) | Q(collaborator__user=request.user) - ) + .filter(Q(leader_id=request.user.id) | Q(collaborator__user=request.user)) .distinct() .count(), }, @@ -312,9 +323,7 @@ def _project_data( return project.id, project.leader.id @staticmethod - def _collabs_queryset( - project_id: int, requested_id: int, leader_id: int - ) -> QuerySet: + def _collabs_queryset(project_id: int, requested_id: int, leader_id: int) -> QuerySet: return Collaborator.objects.exclude( user__id=leader_id ).get( # чтоб случайно лидер сам себя не удалил @@ -322,16 +331,6 @@ def _collabs_queryset( ) -class ProjectSteps(APIView): - permission_classes = [IsStaffOrReadOnly] - - def get(self, request, format=None): - """ - Return a tuple of project steps. - """ - return Response(VERBOSE_STEPS) - - class AchievementList(generics.ListCreateAPIView): queryset = Achievement.objects.get_achievements_for_list_view() serializer_class = AchievementListSerializer @@ -595,9 +594,7 @@ def _project_data( return project.id, project.leader.id @staticmethod - def _collabs_queryset( - project_id: int, requested_id: int, leader_id: int - ) -> QuerySet: + def _collabs_queryset(project_id: int, requested_id: int, leader_id: int) -> QuerySet: return Collaborator.objects.exclude( user__id=leader_id ).get( # чтоб случайно лидер сам себя не удалил @@ -635,6 +632,7 @@ def delete(self, request, project_pk: int) -> Response: class SwitchLeaderRole(generics.GenericAPIView): permission_classes = [IsProjectLeader] queryset = Project.objects.all().select_related("leader") + serializer_class = EmptySerializer @staticmethod def _get_new_leader(user_id: int, project: Project) -> Collaborator: @@ -671,7 +669,7 @@ def patch(self, request, project_pk: int, user_to_leader_pk: int) -> Response: class DuplicateProjectView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, CanBindProjectToProgram] @swagger_auto_schema( request_body=ProjectDuplicateRequestSerializer, @@ -686,21 +684,18 @@ def post(self, request): data = serializer.validated_data original_project = get_object_or_404(Project, id=data["project_id"]) - partner_program = get_object_or_404( - PartnerProgram, id=data["partner_program_id"] - ) + partner_program = get_object_or_404(PartnerProgram, id=data["partner_program_id"]) with transaction.atomic(): new_project = Project.objects.create( name=original_project.name, description=original_project.description, region=original_project.region, - step=original_project.step, hidden_score=original_project.hidden_score, - track=original_project.track, - direction=original_project.direction, actuality=original_project.actuality, - goal=original_project.goal, + target_audience=original_project.target_audience, + trl=original_project.trl, + implementation_deadline=original_project.implementation_deadline, problem=original_project.problem, industry=original_project.industry, image_address=original_project.image_address, @@ -723,3 +718,275 @@ def post(self, request): }, status=status.HTTP_201_CREATED, ) + + +class GoalViewSet(viewsets.ModelViewSet): + queryset = ProjectGoal.objects.select_related("project", "responsible") + serializer_class = ProjectGoalSerializer + permission_classes = [IsProjectLeaderOrReadOnly] + + def get_queryset(self): + project_pk = self.kwargs.get("project_pk") + qs = super().get_queryset() + return qs.filter(project_id=project_pk) if project_pk is not None else qs + + def get_serializer_context(self): + ctx = super().get_serializer_context() + project_pk = self.kwargs.get("project_pk") + if project_pk and "project" not in ctx: + ctx["project"] = get_object_or_404(Project, pk=project_pk) + return ctx + + @swagger_auto_schema( + request_body=ProjectGoalSerializer(many=True), + responses={201: ProjectGoalSerializer(many=True)}, + ) + def create(self, request, *args, **kwargs): + if not isinstance(request.data, list): + return Response( + {"detail": "В теле запроса должен быть массив целей."}, status=400 + ) + serializer = self.get_serializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + created = serializer.save() + out = self.get_serializer(created, many=True) + return Response(out.data, status=status.HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + if isinstance(request.data, list): + return Response( + {"detail": "Обновление выполняется для одной цели по её ID."}, status=400 + ) + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + if isinstance(request.data, list): + return Response( + {"detail": "Частичное обновление выполняется для одной цели по её ID."}, + status=400, + ) + return super().partial_update(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 + + self.check_object_permissions(request, link) + + 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 + + self.check_object_permissions(request, link) + + link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/users/admin.py b/users/admin.py index c10356bd..d393b47b 100644 --- a/users/admin.py +++ b/users/admin.py @@ -8,11 +8,13 @@ from django.http import HttpResponse from django.shortcuts import redirect from django.urls import path +from django.utils.html import format_html from django.utils.timezone import now from core.admin import SkillToObjectInline from core.utils import XlsxFileToExport from mailing.views import MailingTemplateRender +from users.models import UserAchievementFile from users.services.users_activity import UserActivityDataPreparer from .helpers import force_verify_user, send_verification_completed_email @@ -377,9 +379,32 @@ def get_export_users_emails(self, users): return response +class UserAchievementFileInline(admin.TabularInline): + model = UserAchievementFile + extra = 0 + autocomplete_fields = ("file",) + + @admin.register(UserAchievement) class UserAchievementAdmin(admin.ModelAdmin): - list_display = ("id", "title", "status", "user") + list_display = ("id", "title", "status", "user", "year", "file_link") + list_filter = ("year",) + search_fields = ("title", "status", "user__email", "user__username") + inlines = [UserAchievementFileInline] + + def file_link(self, obj): + first_file = obj.files.first() + count = obj.files.count() + + if first_file and getattr(first_file, "link", None): + return format_html( + "открыть ({} файл(ов))", + first_file.link, + count, + ) + return "—" + + file_link.short_description = "Файл(ы)" @admin.register(UserLink) diff --git a/users/helpers.py b/users/helpers.py index 77fa9af3..b513be63 100644 --- a/users/helpers.py +++ b/users/helpers.py @@ -2,11 +2,14 @@ from django.contrib.auth import get_user_model from django.contrib.sites.shortcuts import get_current_site from django.core.cache import cache +from django.db import IntegrityError, transaction from django.urls import reverse from django.utils import timezone from django.utils.timezone import now +from rest_framework.exceptions import ValidationError from rest_framework_simplejwt.tokens import RefreshToken +from files.models import UserFile from mailing.utils import send_mail from users.constants import PROTOCOL from users.models import UserAchievement, UserLink @@ -54,24 +57,191 @@ def check_related_fields_update(data, pk): update_links(data.get("links"), pk) +def _extract_file_links(raw_files) -> list[str]: + """ + Normalize file input payload into a list of links. + Accepts either a list of strings or a list of dicts with a `link` key. + """ + + if not raw_files: + return [] + + if isinstance(raw_files, str): + raw_files = [raw_files] + + if not isinstance(raw_files, (list, tuple)): + return [] + + links: list[str] = [] + for item in raw_files: + if isinstance(item, str): + links.append(item) + elif isinstance(item, dict): + link = item.get("link") + if isinstance(link, str): + links.append(link) + # keep original order but remove empties/duplicates + seen = set() + deduped = [] + for link in links: + if link and link not in seen: + seen.add(link) + deduped.append(link) + return deduped + + +def _resolve_user_files(file_links: list[str], user_id: int) -> list[UserFile]: + """ + Resolve file links to UserFile objects, validating ownership. + """ + + if not file_links: + return [] + + files = UserFile.objects.filter(link__in=file_links) + files_by_link = {f.link: f for f in files} + + missing = [link for link in file_links if link not in files_by_link] + if missing: + raise ValidationError({"achievements": [f"Файлы не найдены: {missing}"]}) + + wrong_owner = [ + link + for link, file in files_by_link.items() + if file.user_id is None or file.user_id != user_id + ] + if wrong_owner: + raise ValidationError( + { + "achievements": [ + "Нельзя привязать файлы: нет владельца или владелец другой " + f"({wrong_owner})" + ] + } + ) + + # Preserve original ordering + return [files_by_link[link] for link in file_links] + + +@transaction.atomic def update_achievements(achievements, pk): """ Bootleg version of updating achievements via user """ - # delete all old achievements - UserAchievement.objects.filter(user_id=pk).delete() - # create new achievements - UserAchievement.objects.bulk_create( - [ - UserAchievement( + if achievements is None: + return + + if not isinstance(achievements, list): + raise ValidationError({"achievements": ["Должен быть списком объектов."]}) + + existing_achievements = { + achievement.id: achievement + for achievement in UserAchievement.objects.filter(user_id=pk) + } + existing_ids = set(existing_achievements.keys()) + payload_existing_ids = { + achievement.get("id") + for achievement in achievements + if isinstance(achievement, dict) + and achievement.get("id") in existing_achievements + } + stale_ids = existing_ids - payload_existing_ids + + if stale_ids: + UserAchievement.objects.filter(id__in=stale_ids).delete() + for stale_id in stale_ids: + existing_achievements.pop(stale_id, None) + + seen_pairs: set[tuple[str, int | None]] = set() + + for achievement_payload in achievements: + if not isinstance(achievement_payload, dict): + raise ValidationError({"achievements": ["Каждое достижение должно быть объектом."]}) + + achievement_id = achievement_payload.get("id") + has_year_key = "year" in achievement_payload + raw_files = None + files_key_present = False + + if "file_links" in achievement_payload: + raw_files = achievement_payload.get("file_links") + files_key_present = True + elif "files" in achievement_payload: + raw_files = achievement_payload.get("files") + files_key_present = True + + file_links = ( + _extract_file_links(raw_files) if files_key_present else None + ) + + if achievement_id and achievement_id in existing_achievements: + achievement_instance = existing_achievements[achievement_id] + title = achievement_payload.get("title", achievement_instance.title) + status = achievement_payload.get("status", achievement_instance.status) + year = ( + achievement_payload.get("year") + if has_year_key + else achievement_instance.year + ) + + achievement_instance.title = title + achievement_instance.status = status + if has_year_key: + achievement_instance.year = year + else: + achievement_instance = UserAchievement( user_id=pk, - title=achievement.get("title"), - status=achievement.get("status"), + title=achievement_payload.get("title"), + status=achievement_payload.get("status"), + year=achievement_payload.get("year"), ) - for achievement in achievements - ] - ) + title = achievement_instance.title + year = achievement_instance.year + + duplicate_key = (title, year) + if duplicate_key in seen_pairs: + raise ValidationError( + { + "achievements": [ + f"Достижение с названием '{title}' и годом '{year}' указано несколько раз." + ] + } + ) + + existing_conflict = ( + UserAchievement.objects.filter( + user_id=pk, title=title, year=year + ) + .exclude(id=achievement_instance.id) + .exists() + ) + if existing_conflict: + raise ValidationError( + { + "achievements": [ + f"Достижение с названием '{title}' за {year} уже существует." + ] + } + ) + + try: + achievement_instance.save() + except IntegrityError: + raise ValidationError( + { + "achievements": [ + f"Достижение с названием '{title}' за {year} уже существует." + ] + } + ) from None + + seen_pairs.add(duplicate_key) + + if file_links is not None: + user_files = _resolve_user_files(file_links, pk) + achievement_instance.files.set(user_files) def update_links(links, pk): diff --git a/users/managers.py b/users/managers.py index d2f2eb63..a09b2e6a 100644 --- a/users/managers.py +++ b/users/managers.py @@ -1,6 +1,8 @@ +from django.apps import apps from django.contrib.auth.hashers import make_password from django.contrib.auth.models import UserManager -from django.db.models import Manager +from django.db import models +from django.db.models import Manager, Prefetch from users.constants import MEMBER @@ -53,19 +55,35 @@ def _create_user(self, email, password, **extra_fields): return user -class UserAchievementManager(Manager): - def get_achievements_for_list_view(self): +FILE_FIELDS = ( + "id", + "name", + "extension", + "mime_type", + "size", + "link", + "user_id", + "datetime_uploaded", +) + + +class UserAchievementManager(models.Manager): + def _with_user_and_files(self): + UserFile = apps.get_model("files", "UserFile") return ( self.get_queryset() .select_related("user") - .only("id", "title", "status", "user__id") + .prefetch_related(Prefetch("files", queryset=UserFile.objects.all())) + ) + + def get_achievements_for_list_view(self): + return self._with_user_and_files().only( + "id", "title", "status", "year", "user_id" ) def get_achievements_for_detail_view(self): - return ( - self.get_queryset() - .select_related("user") - .only("id", "title", "status", "user") + return self._with_user_and_files().only( + "id", "title", "status", "year", "user_id" ) diff --git a/users/migrations/0055_alter_customuser_avatar_alter_customuser_email.py b/users/migrations/0055_alter_customuser_avatar_alter_customuser_email.py new file mode 100644 index 00000000..2bbb6595 --- /dev/null +++ b/users/migrations/0055_alter_customuser_avatar_alter_customuser_email.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.11 on 2025-09-02 07:59 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0054_alter_customuser_first_name_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="customuser", + name="avatar", + field=models.URLField( + blank=True, + null=True, + validators=[ + django.core.validators.URLValidator(message="Введите корректный URL") + ], + ), + ), + migrations.AlterField( + model_name="customuser", + name="email", + field=models.EmailField( + error_messages={"unique": "Пользователь с таким email уже существует"}, + max_length=254, + unique=True, + ), + ), + ] diff --git a/users/migrations/0056_userachievementfile_userachievement_year_and_more.py b/users/migrations/0056_userachievementfile_userachievement_year_and_more.py new file mode 100644 index 00000000..f7b6f5f0 --- /dev/null +++ b/users/migrations/0056_userachievementfile_userachievement_year_and_more.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.24 on 2025-10-20 08:06 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0007_auto_20230929_1727"), + ("users", "0055_alter_customuser_avatar_alter_customuser_email"), + ] + + operations = [ + migrations.CreateModel( + name="UserAchievementFile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + options={ + "verbose_name": "Файл достижения", + "verbose_name_plural": "Файлы достижения", + }, + ), + migrations.AddField( + model_name="userachievement", + name="year", + field=models.PositiveSmallIntegerField( + blank=True, + db_index=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1900), + django.core.validators.MaxValueValidator(2025), + ], + verbose_name="Год достижения", + ), + ), + migrations.AddConstraint( + model_name="userachievement", + constraint=models.UniqueConstraint( + fields=("user", "title", "year"), name="uniq_user_achievement_title_year" + ), + ), + migrations.AddField( + model_name="userachievementfile", + name="achievement", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="files", + to="users.userachievement", + verbose_name="Достижение", + ), + ), + migrations.AddField( + model_name="userachievementfile", + name="file", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="achievement_links", + to="files.userfile", + verbose_name="Файл", + ), + ), + migrations.AddConstraint( + model_name="userachievementfile", + constraint=models.UniqueConstraint( + fields=("achievement", "file"), name="uniq_achievement_file_link" + ), + ), + ] diff --git a/users/migrations/0057_alter_usereducation_description_and_more.py b/users/migrations/0057_alter_usereducation_description_and_more.py new file mode 100644 index 00000000..d46cfe4f --- /dev/null +++ b/users/migrations/0057_alter_usereducation_description_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.24 on 2025-10-23 10:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0056_userachievementfile_userachievement_year_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="usereducation", + name="description", + field=models.TextField( + blank=True, + max_length=1000, + null=True, + verbose_name="Направление обучения", + ), + ), + migrations.AlterField( + model_name="userworkexperience", + name="description", + field=models.TextField( + blank=True, + max_length=1000, + null=True, + verbose_name="Краткое описание деятельности", + ), + ), + ] diff --git a/users/migrations/0058_userachievement_files_and_more.py b/users/migrations/0058_userachievement_files_and_more.py new file mode 100644 index 00000000..0530c787 --- /dev/null +++ b/users/migrations/0058_userachievement_files_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.24 on 2025-10-24 06:41 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0007_auto_20230929_1727"), + ("users", "0057_alter_usereducation_description_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="userachievement", + name="files", + field=models.ManyToManyField( + blank=True, + related_name="achievements", + related_query_name="achievement", + through="users.UserAchievementFile", + to="files.userfile", + ), + ), + migrations.AlterField( + model_name="userachievementfile", + name="achievement", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="file_links", + related_query_name="file_link", + to="users.userachievement", + verbose_name="Достижение", + ), + ), + migrations.AlterField( + model_name="userachievementfile", + name="file", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="achievement_links", + related_query_name="achievement_link", + to="files.userfile", + verbose_name="Файл", + ), + ), + ] diff --git a/users/models.py b/users/models.py index d3e3a91f..815ee521 100644 --- a/users/models.py +++ b/users/models.py @@ -1,9 +1,10 @@ +import datetime from functools import partial from django.contrib.auth.models import AbstractUser from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError -from django.core.validators import URLValidator +from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator from django.db import models from django.db.models import QuerySet from django.utils import timezone @@ -211,9 +212,7 @@ def calculate_ordering_score(self) -> int: def get_project_chats(self) -> QuerySet: from chats.models import ProjectChat - user_project_ids = self.collaborations.all().values_list( - "project_id", flat=True - ) + user_project_ids = self.collaborations.all().values_list("project_id", flat=True) return ProjectChat.objects.filter(project__in=user_project_ids) def get_full_name(self) -> str: @@ -258,12 +257,29 @@ class UserAchievement(models.Model): title = models.CharField(max_length=256) status = models.CharField(max_length=256) - + year = models.PositiveSmallIntegerField( + "Год достижения", + null=True, + blank=True, + db_index=True, + validators=[ + MinValueValidator(1900), + MaxValueValidator(datetime.date.today().year), + ], + ) user = models.ForeignKey( CustomUser, on_delete=models.CASCADE, related_name="achievements", ) + files = models.ManyToManyField( + "files.UserFile", + through="UserAchievementFile", + through_fields=("achievement", "file"), + related_name="achievements", + related_query_name="achievement", + blank=True, + ) objects = UserAchievementManager() @@ -274,6 +290,57 @@ class Meta(TypedModelMeta): verbose_name = "Достижение" verbose_name_plural = "Достижения" + constraints = [ + models.UniqueConstraint( + fields=["user", "title", "year"], + name="uniq_user_achievement_title_year", + ), + ] + + +class UserAchievementFile(models.Model): + ALLOWED_EXTENSIONS = {"pdf", "doc", "docx", "jpg", "jpeg", "png", "webp"} + MAX_UPLOAD_SIZE = 50 * 1024 * 1024 + + achievement = models.ForeignKey( + UserAchievement, + on_delete=models.CASCADE, + related_name="file_links", + related_query_name="file_link", + verbose_name="Достижение", + ) + file = models.ForeignKey( + "files.UserFile", + on_delete=models.CASCADE, + related_name="achievement_links", + related_query_name="achievement_link", + verbose_name="Файл", + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["achievement", "file"], name="uniq_achievement_file_link" + ) + ] + verbose_name = "Файл достижения" + verbose_name_plural = "Файлы достижения" + + def clean(self): + super().clean() + if not self.file or not self.achievement: + return + + if self.file.user_id is None or self.file.user_id != self.achievement.user_id: + raise ValidationError("Файл должен принадлежать тому же пользователю.") + + if self.file.size and self.file.size > self.MAX_UPLOAD_SIZE: + raise ValidationError("Размер файла превышает 50 МБ.") + + ext = (self.file.extension or "").lower() + if ext and ext not in self.ALLOWED_EXTENSIONS: + raise ValidationError("Недопустимое расширение файла.") + class AbstractUserWithRole(models.Model): """ @@ -556,6 +623,12 @@ class UserEducation(AbstractUserExperience): null=True, verbose_name="Статус по обучению", ) + description = models.TextField( + max_length=1000, + null=True, + blank=True, + verbose_name="Направление обучения", + ) class Meta: verbose_name = "Образование пользователя" @@ -583,6 +656,12 @@ class UserWorkExperience(AbstractUserExperience): related_name="work_experience", verbose_name="Пользователь", ) + description = models.TextField( + max_length=1000, + null=True, + blank=True, + verbose_name="Краткое описание деятельности", + ) job_position = models.CharField( max_length=256, null=True, diff --git a/users/serializers.py b/users/serializers.py index c43bc1d9..a210adc9 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -14,6 +14,8 @@ from core.serializers import SkillToObjectSerializer from core.services import get_views_count from core.utils import get_user_online_cache_key +from files.models import UserFile +from files.serializers import UserFileSerializer from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from projects.models import Collaborator, Project from projects.validators import validate_project @@ -25,6 +27,7 @@ Member, Mentor, UserAchievement, + UserAchievementFile, UserEducation, UserLanguages, UserSkillConfirmation, @@ -34,11 +37,108 @@ from users.validators import specialization_exists_validator -class AchievementListSerializer(serializers.ModelSerializer[UserAchievement]): +class UserFileReadSerializer(serializers.ModelSerializer): + class Meta: + model = UserFile + fields = ("link", "name", "extension", "mime_type", "size") + + +class FileLinkField(serializers.SlugRelatedField): + """ + write-only: принимает link, маппит на UserFile текущего пользователя. + """ + + def get_queryset(self): + request = self.context.get("request") + qs = UserFile.objects.all() + if request and request.user.is_authenticated: + return qs.filter(user=request.user) + return qs.none() + + +class AchievementListSerializer(serializers.ModelSerializer): + year = serializers.IntegerField(required=False, allow_null=True) + files = UserFileSerializer(many=True, read_only=True) + class Meta: model = UserAchievement - fields = ["id", "title", "status"] - ref_name = "Users" + fields = ["id", "title", "status", "year", "files"] + + +class AchievementDetailSerializer(serializers.ModelSerializer): + files = UserFileSerializer(many=True, read_only=True) + file_links = FileLinkField( + slug_field="link", + many=True, + write_only=True, + required=False, + ) + + class Meta: + model = UserAchievement + fields = [ + "id", + "title", + "status", + "year", + "files", + "file_links", + ] + + @transaction.atomic + def create(self, validated_data): + file_objs = validated_data.pop("file_links", []) + achievement = super().create(validated_data) + if file_objs: + achievement.files.set(file_objs) + return achievement + + @transaction.atomic + def update(self, instance, validated_data): + file_objs = validated_data.pop("file_links", None) + achievement = super().update(instance, validated_data) + if file_objs is not None: + achievement.files.set(file_objs) + return achievement + + +class AchievementFileReadSerializer(serializers.ModelSerializer): + file = UserFileReadSerializer() + + class Meta: + model = UserAchievementFile + fields = ("file",) + + +class FilesWriteField(serializers.ListField): + """ + Принимает список ссылок (UserFile.link — первичный ключ). + Проверяет существование и владельца (user не NULL и совпадает с request.user). + """ + + child = serializers.URLField() + + def to_internal_value(self, data): + values = super().to_internal_value(data) + request = self.context.get("request") + qs = UserFile.objects.filter(link__in=values) + files_by_link = {uf.link: uf for uf in qs} + + missing = [v for v in values if v not in files_by_link] + if missing: + raise serializers.ValidationError(f"Файлы не найдены: {missing}") + + if request and request.user.is_authenticated: + wrong_owner = [] + for uf in files_by_link.values(): + if uf.user_id is None or uf.user_id != request.user.id: + wrong_owner.append(uf.link) + if wrong_owner: + raise serializers.ValidationError( + f"Нельзя привязать файлы: нет владельца или владелец другой ({wrong_owner})" + ) + + return list(files_by_link.values()) class CustomListField(serializers.ListField): @@ -47,9 +147,7 @@ def to_representation(self, data): if isinstance(data, list): return data return [ - i.replace("'", "") - for i in data.strip("][").split(",") - if i.replace("'", "") + i.replace("'", "") for i in data.strip("][").split(",") if i.replace("'", "") ] @@ -151,9 +249,7 @@ def to_representation(self, instance): """Returns correct data about user in `confirmed_by`.""" data = super().to_representation(instance) data.pop("skill_to_object", None) - data["confirmed_by"] = UserDataConfirmationSerializer( - instance.confirmed_by - ).data + data["confirmed_by"] = UserDataConfirmationSerializer(instance.confirmed_by).data return data @@ -350,10 +446,11 @@ class Meta: class UserProgramsSerializer(serializers.ModelSerializer): year = serializers.SerializerMethodField() + logo = serializers.CharField(source="image_address", read_only=True) class Meta: model = PartnerProgram - fields = ["id", "tag", "name", "year"] + fields = ["id", "tag", "name", "year", "logo"] def get_year(self, program: PartnerProgram) -> int | None: user_program_profile = PartnerProgramUserProfile.objects.filter( @@ -528,10 +625,7 @@ def update(self, instance, validated_data): if attr in IMMUTABLE_FIELDS + USER_TYPE_FIELDS + RELATED_FIELDS: continue if attr == "user_type": - if ( - value == instance.user_type - or value not in user_types_to_attr.keys() - ): + if value == instance.user_type or value not in user_types_to_attr.keys(): continue # we can't change user type to Member if value == CustomUser.MEMBER: @@ -827,18 +921,6 @@ class Meta: ] -class AchievementDetailSerializer(serializers.ModelSerializer[UserAchievement]): - class Meta: - model = UserAchievement - fields = [ - "id", - "title", - "status", - "user", - ] - ref_name = "Users" - - class EmailSerializer(serializers.Serializer): email = serializers.EmailField() diff --git a/users/validators.py b/users/validators.py index 61e9e527..7d6e506c 100644 --- a/users/validators.py +++ b/users/validators.py @@ -45,11 +45,11 @@ def specialization_exists_validator(pk: int): def user_experience_years_range_validator(value: int): """ Check range for choice entry/completion year. - (2000 - `now.year`) + (1971 - `now.year`) """ - if value not in range(2000, timezone.now().year + 1): + if value not in range(1971, timezone.now().year + 1): raise DjangoValidationError( - f"Год должен быть в диапазоне 2000 - {timezone.now().year}" + f"Год должен быть в диапазоне 1971 - {timezone.now().year}" ) diff --git a/users/views.py b/users/views.py index 534c80ef..0d10eeec 100644 --- a/users/views.py +++ b/users/views.py @@ -48,11 +48,7 @@ VERIFY_EMAIL_REDIRECT_URL, OnboardingStage, ) -from users.helpers import ( - check_related_fields_update, - force_verify_user, - verify_email, -) +from users.helpers import check_related_fields_update, force_verify_user, verify_email from users.models import LikesOnProject, UserAchievement, UserSkillConfirmation from users.permissions import IsAchievementOwnerOrReadOnly from users.serializers import ( @@ -111,9 +107,7 @@ def post(self, request, *args, **kwargs): verify_email(user, request) - return Response( - serializer.data, status=status.HTTP_201_CREATED, headers=headers - ) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) class LikedProjectList(ListAPIView): @@ -314,9 +308,7 @@ def get(self, request): token = request.GET.get("token") try: - payload = jwt.decode( - jwt=token, key=settings.SECRET_KEY, algorithms=["HS256"] - ) + payload = jwt.decode(jwt=token, key=settings.SECRET_KEY, algorithms=["HS256"]) user = User.objects.get(id=payload["user_id"]) access_token = RefreshToken.for_user(user).access_token refresh_token = RefreshToken.for_user(user) @@ -346,35 +338,65 @@ def get(self, request): class AchievementList(ListCreateAPIView): - queryset = UserAchievement.objects.get_achievements_for_list_view() - serializer_class = AchievementListSerializer - permission_classes = [IsAchievementOwnerOrReadOnly] + """ + GET /api/users/achievements/ + POST /api/users/achievements/ + """ + + queryset = ( + UserAchievement.objects.get_achievements_for_list_view() + .select_related("user") + .prefetch_related("files") + ) + + def get_permissions(self): + if self.request.method == "POST": + return [permissions.IsAuthenticated()] + return [permissions.AllowAny()] + + def get_serializer_class(self): + return ( + AchievementListSerializer + if self.request.method == "GET" + else AchievementDetailSerializer + ) def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) + if not request.user.is_authenticated: + return Response( + {"detail": "Authentication required."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + serializer = AchievementDetailSerializer( + data=request.data, context={"request": request} + ) serializer.is_valid(raise_exception=True) - serializer.validated_data["user"] = request.user - # warning for someone who tries to set user variable (the user will always be yourself anyway) - if ( - request.data.get("user") is not None - and request.data.get("user") != request.user.id - ): + + if request.data.get("user") and request.data.get("user") != request.user.id: return Response( - { - "error": "you can't edit USER field for this view since " - "you can't create achievements for other people" - }, + {"error": "Нельзя создавать достижения для другого пользователя."}, status=status.HTTP_403_FORBIDDEN, ) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response( - serializer.data, status=status.HTTP_201_CREATED, headers=headers - ) + + achievement = serializer.save(user=request.user) + out = AchievementDetailSerializer(achievement, context={"request": request}) + headers = self.get_success_headers(out.data) + return Response(out.data, status=status.HTTP_201_CREATED, headers=headers) class AchievementDetail(RetrieveUpdateDestroyAPIView): - queryset = UserAchievement.objects.get_achievements_for_detail_view() + """ + GET /api/users/achievements/{id}/ + PATCH/PUT /api/users/achievements/{id}/ + DELETE /api/users/achievements/{id}/ + """ + + queryset = ( + UserAchievement.objects.get_achievements_for_detail_view() + .select_related("user") + .prefetch_related("files") + ) serializer_class = AchievementDetailSerializer permission_classes = [IsAchievementOwnerOrReadOnly] @@ -506,6 +528,9 @@ class UserSubscribedProjectsList(ListAPIView): pagination_class = Pagination def get_queryset(self): + if "pk" not in self.kwargs: + return Project.objects.none() + try: user = User.objects.get(pk=self.kwargs["pk"]) return user.subscribed_projects.all() diff --git a/vacancy/managers.py b/vacancy/managers.py index f086a39d..fe7e3fd9 100644 --- a/vacancy/managers.py +++ b/vacancy/managers.py @@ -16,18 +16,26 @@ def get_vacancy_for_list_view(self): "description", "project__id", "is_active", + "datetime_created", ) ) def get_vacancy_for_detail_view(self): return ( self.get_queryset() - .select_related("project") + .select_related("project", "project__industry") + .prefetch_related("project__links") .only( "role", "required_skills", "description", "project", + "project__name", + "project__description", + "project__image_address", + "project__is_company", + "project__industry", + "project__links__link", "is_active", "datetime_created", "datetime_updated", diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 3b4cf59d..5dda57c7 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.utils import timezone from rest_framework import serializers from core.models import Skill, SkillToObject @@ -72,11 +73,33 @@ def get_response_count(self, obj): return obj.vacancy_requests.filter(is_approved=None).count() +def _format_datetime_with_seconds(value): + if value is None: + return None + if timezone.is_aware(value): + value = timezone.localtime(value) + try: + return value.isoformat(timespec="seconds") + except TypeError: + # Fallback for Python versions without timespec support + return value.replace(microsecond=0).isoformat() + + +class VacancyCreationDateSerializerMixin(serializers.Serializer): + date_create_time = serializers.SerializerMethodField() + + @staticmethod + def get_date_create_time(obj: Vacancy): + return _format_datetime_with_seconds(getattr(obj, "datetime_created", None)) + + class ProjectVacancyListSerializer( + VacancyCreationDateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, RequiredSkillsSerializerMixin[Vacancy], ): + class Meta: model = Vacancy fields = [ @@ -89,10 +112,17 @@ class Meta: "is_active", "datetime_closed", "response_count", + "date_create_time", ] class ProjectForVacancySerializer(serializers.ModelSerializer[Project]): + links = serializers.SerializerMethodField() + + @staticmethod + def get_links(project: Project) -> list[str]: + return [link.link for link in project.links.all()] + class Meta: model = Project fields = [ @@ -101,10 +131,13 @@ class Meta: "description", "image_address", "is_company", + "industry", + "links", ] class VacancyDetailSerializer( + VacancyCreationDateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, AbstractVacancyEnumFields, @@ -127,6 +160,7 @@ class Meta: "datetime_updated", "datetime_closed", "response_count", + "date_create_time", "required_experience", "work_schedule", "work_format", @@ -136,10 +170,12 @@ class Meta: class VacancyListSerializer( + VacancyCreationDateSerializerMixin, serializers.ModelSerializer, RequiredSkillsSerializerMixin[Vacancy], AbstractVacancyReadOnlyFields, ): + class Meta: model = Vacancy fields = [ @@ -151,6 +187,7 @@ class Meta: "is_active", "datetime_closed", "response_count", + "date_create_time", ] read_only_fields = [ "project", @@ -194,11 +231,13 @@ def validate(self, data): class ProjectVacancyCreateListSerializer( + VacancyCreationDateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, AbstractVacancyEnumFields, RequiredSkillsWriteSerializerMixin[Vacancy], ): + def create(self, validated_data): project = validated_data["project"] if project.leader != self.context["request"].user: @@ -247,6 +286,7 @@ class Meta: "is_active", "datetime_closed", "response_count", + "date_create_time", "required_experience", "work_schedule", "work_format", @@ -307,12 +347,6 @@ def create(self, validated_data): return vacancy_response -class VacancyResponseFullFileInfoListSerializer(VacancyResponseListSerializer): - """Returns full file info.""" - - accompanying_file = UserFileSerializer(read_only=True) - - class VacancyResponseDetailSerializer(serializers.ModelSerializer[VacancyResponse]): user = UserDetailSerializer(many=False, read_only=True) vacancy = VacancyListSerializer(many=False, read_only=True) @@ -340,3 +374,15 @@ class Meta: class VacancyResponseAcceptSerializer(VacancyResponseDetailSerializer[VacancyResponse]): is_approved = serializers.BooleanField(required=True, read_only=False) + + +class VacancyResponseFullFileInfoListSerializer(VacancyResponseListSerializer): + """Returns full file info.""" + + accompanying_file = UserFileSerializer(read_only=True) + + +class VacancyResponseDetailReadSerializer(VacancyResponseDetailSerializer): + """Returns full file info for detail view without breaking writes.""" + + accompanying_file = UserFileSerializer(read_only=True) diff --git a/vacancy/tests.py b/vacancy/tests.py index 356c5212..fb6d3596 100644 --- a/vacancy/tests.py +++ b/vacancy/tests.py @@ -33,7 +33,6 @@ def setUp(self): name="Test", description="Test", industry=Industry.objects.create(name="Test"), - step=1, leader=self.user_project_owner, ) self.vacancy_create_data = { diff --git a/vacancy/views.py b/vacancy/views.py index 78994bf9..8fd1edd0 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -22,6 +22,8 @@ VacancyDetailSerializer, VacancyResponseAcceptSerializer, VacancyResponseDetailSerializer, + VacancyResponseDetailReadSerializer, + VacancyResponseFullFileInfoListSerializer, VacancyResponseListSerializer, ProjectVacancyCreateListSerializer, ) @@ -76,6 +78,11 @@ class VacancyResponseList( permission_classes = [permissions.IsAuthenticatedOrReadOnly] serializer_class = VacancyResponseListSerializer + def get_serializer_class(self): + if self.request.method == "GET": + return VacancyResponseFullFileInfoListSerializer + return super().get_serializer_class() + def get(self, request, *args, **kwargs): """retrieve all responses for certain vacancy""" # note: doesn't raise an error if the vacancy_id passed is non-existent @@ -127,6 +134,11 @@ class VacancyResponseDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = VacancyResponseDetailSerializer permission_classes = [IsVacancyResponseOwnerOrReadOnly] + def get_serializer_class(self): + if self.request.method == "GET": + return VacancyResponseDetailReadSerializer + return super().get_serializer_class() + class VacancyResponseAccept(generics.GenericAPIView): queryset = VacancyResponse.objects.get_vacancy_response_for_detail_view() @@ -210,7 +222,7 @@ def post(self, request, pk): class UserVacancyResponses(ListAPIView): - serializer_class = VacancyResponseListSerializer + serializer_class = VacancyResponseFullFileInfoListSerializer permission_classes = [IsVacancyResponseOwnerOrReadOnly] pagination_class = VacancyPagination