diff --git a/invites/admin.py b/invites/admin.py index 4913dd7d..30527640 100644 --- a/invites/admin.py +++ b/invites/admin.py @@ -5,4 +5,11 @@ @admin.register(Invite) class InviteAdmin(admin.ModelAdmin): - fields = ["project", "user", "motivational_letter", "role", "is_accepted"] + fields = [ + "project", + "user", + "motivational_letter", + "role", + "specialization", + "is_accepted", + ] diff --git a/invites/migrations/0002_invite_specialization.py b/invites/migrations/0002_invite_specialization.py new file mode 100644 index 00000000..889e85a3 --- /dev/null +++ b/invites/migrations/0002_invite_specialization.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.11 on 2025-07-08 11:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("invites", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="invite", + name="specialization", + field=models.CharField( + blank=True, + default=None, + max_length=100, + null=True, + verbose_name="Специализация", + ), + ), + ] diff --git a/invites/models.py b/invites/models.py index 77e97207..42118726 100644 --- a/invites/models.py +++ b/invites/models.py @@ -1,9 +1,9 @@ from django.db import models +from django_stubs_ext.db.models import TypedModelMeta from invites.managers import InviteManager from projects.models import Project from users.models import CustomUser -from django_stubs_ext.db.models import TypedModelMeta class Invite(models.Model): @@ -27,6 +27,13 @@ class Invite(models.Model): max_length=4096, blank=True, null=True, default=None ) role = models.CharField(max_length=128, blank=True, null=True) + specialization = models.CharField( + max_length=100, + blank=True, + null=True, + default=None, + verbose_name="Специализация", + ) is_accepted = models.BooleanField(blank=False, null=True, default=None) datetime_created = models.DateTimeField( @@ -39,7 +46,9 @@ class Invite(models.Model): objects = InviteManager() def __str__(self) -> str: - return f'Invite from project "{self.project.name}" to {self.user.get_full_name()}' + return ( + f'Invite from project "{self.project.name}" to {self.user.get_full_name()}' + ) class Meta(TypedModelMeta): verbose_name = "Приглашение" diff --git a/invites/serializers.py b/invites/serializers.py index 1e05877a..1ea64c59 100644 --- a/invites/serializers.py +++ b/invites/serializers.py @@ -14,6 +14,7 @@ class Meta: "user", "motivational_letter", "role", + "specialization", "is_accepted", ] @@ -21,6 +22,9 @@ class Meta: class InviteDetailSerializer(serializers.ModelSerializer[Invite]): user = UserDetailSerializer(many=False, read_only=True) project = ProjectListSerializer(many=False, read_only=True) + specialization = serializers.CharField( + required=False, allow_null=True, allow_blank=True + ) class Meta: model = Invite @@ -30,6 +34,7 @@ class Meta: "user", "motivational_letter", "role", + "specialization", "is_accepted", "datetime_created", "datetime_updated", diff --git a/invites/views.py b/invites/views.py index 474b5d61..3563c0a6 100644 --- a/invites/views.py +++ b/invites/views.py @@ -4,7 +4,7 @@ from invites.filters import InviteFilter from invites.models import Invite -from invites.serializers import InviteListSerializer, InviteDetailSerializer +from invites.serializers import InviteDetailSerializer, InviteListSerializer from projects.models import Collaborator @@ -49,7 +49,10 @@ def post(self, request, *args, **kwargs): return Response(status=status.HTTP_403_FORBIDDEN) # add user to project collaborators Collaborator.objects.create( - user=invite.user, project=invite.project, role=invite.role + user=invite.user, + project=invite.project, + role=invite.role, + specialization=invite.specialization, ) invite.is_accepted = True invite.save() diff --git a/projects/admin.py b/projects/admin.py index 62785951..d7cbce7e 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -1,10 +1,11 @@ from django.contrib import admin from projects.models import ( - DefaultProjectCover, - Project, Achievement, Collaborator, + DefaultProjectAvatar, + DefaultProjectCover, + Project, ProjectLink, ProjectNews, ) @@ -17,11 +18,75 @@ 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", + ) + + fieldsets = ( + ( + "Основная информация", + { + "fields": ( + "name", + "description", + "leader", + "industry", + "region", + "step", + "draft", + "is_company", + ) + }, + ), + ( + "Для проектов ПД МосПолитеха", + { + "fields": ( + "track", + "direction", + "actuality", + "goal", + "problem", + ) + }, + ), + ( + "Медиа и обложка", + { + "fields": ( + "presentation_address", + "image_address", + "cover", + "cover_image_address", + ) + }, + ), + ( + "Служебные поля", + { + "fields": ( + "hidden_score", + "datetime_created", + "datetime_updated", + ) + }, + ), + ) + readonly_fields = ("datetime_created", "datetime_updated") @admin.register(ProjectNews) @@ -77,3 +142,8 @@ class DefaultProjectCoverAdmin(admin.ModelAdmin): "datetime_created", "datetime_updated", ) + + +@admin.register(DefaultProjectAvatar) +class DefaultProjectAvatarAdmin(admin.ModelAdmin): + list_display = ("id", "image", "datetime_created") diff --git a/projects/migrations/0024_project_actuality_project_direction_project_goal_and_more.py b/projects/migrations/0024_project_actuality_project_direction_project_goal_and_more.py new file mode 100644 index 00000000..7c028914 --- /dev/null +++ b/projects/migrations/0024_project_actuality_project_direction_project_goal_and_more.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.11 on 2025-07-03 08:53 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0023_project_cover_image_address"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="actuality", + field=models.TextField( + blank=True, + help_text="Почему проект важен (до 1000\u202fсимв.)", + null=True, + validators=[django.core.validators.MaxLengthValidator(1000)], + verbose_name="Актуальность", + ), + ), + migrations.AddField( + model_name="project", + name="direction", + field=models.CharField( + blank=True, + help_text="Более общее направление деятельности проекта", + max_length=256, + null=True, + verbose_name="Направление", + ), + ), + migrations.AddField( + model_name="project", + name="goal", + field=models.CharField( + blank=True, + help_text="Главная цель проекта (до 500\u202fсимв.)", + max_length=500, + null=True, + verbose_name="Цель", + ), + ), + migrations.AddField( + model_name="project", + name="problem", + field=models.TextField( + blank=True, + help_text="Какую проблему решает проект (до 1000\u202fсимв.)", + null=True, + validators=[django.core.validators.MaxLengthValidator(1000)], + verbose_name="Проблема", + ), + ), + migrations.AddField( + model_name="project", + name="specialty", + field=models.CharField( + blank=True, + help_text="Специализация проекта", + max_length=256, + null=True, + verbose_name="Специальность", + ), + ), + migrations.AddField( + model_name="project", + name="track", + field=models.CharField( + blank=True, + help_text="Направление/курс, в рамках которого реализуется проект", + max_length=256, + null=True, + verbose_name="Трек", + ), + ), + ] diff --git a/projects/migrations/0025_remove_project_specialty.py b/projects/migrations/0025_remove_project_specialty.py new file mode 100644 index 00000000..47638bf0 --- /dev/null +++ b/projects/migrations/0025_remove_project_specialty.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.11 on 2025-07-04 13:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0024_project_actuality_project_direction_project_goal_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="project", + name="specialty", + ), + ] diff --git a/projects/migrations/0026_collaborator_specialization.py b/projects/migrations/0026_collaborator_specialization.py new file mode 100644 index 00000000..03245fa6 --- /dev/null +++ b/projects/migrations/0026_collaborator_specialization.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.11 on 2025-07-08 11:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0025_remove_project_specialty"), + ] + + operations = [ + migrations.AddField( + model_name="collaborator", + name="specialization", + field=models.CharField( + blank=True, + default=None, + help_text="Направления работы участника в рамках проекта", + max_length=100, + null=True, + verbose_name="Специализация", + ), + ), + ] diff --git a/projects/migrations/0027_alter_defaultprojectcover_datetime_created_and_more.py b/projects/migrations/0027_alter_defaultprojectcover_datetime_created_and_more.py new file mode 100644 index 00000000..0f755693 --- /dev/null +++ b/projects/migrations/0027_alter_defaultprojectcover_datetime_created_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.11 on 2025-07-11 11:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0007_auto_20230929_1727"), + ("projects", "0026_collaborator_specialization"), + ] + + operations = [ + migrations.AlterField( + model_name="defaultprojectcover", + name="datetime_created", + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name="defaultprojectcover", + name="datetime_updated", + field=models.DateTimeField(auto_now=True), + ), + migrations.AlterField( + model_name="defaultprojectcover", + name="image", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="files.userfile", + ), + ), + migrations.CreateModel( + name="DefaultProjectAvatar", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("datetime_created", models.DateTimeField(auto_now_add=True)), + ("datetime_updated", models.DateTimeField(auto_now=True)), + ( + "image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="files.userfile", + ), + ), + ], + options={ + "verbose_name": "Аватарка проекта", + "verbose_name_plural": "Аватарки проектов", + }, + ), + ] diff --git a/projects/models.py b/projects/models.py index b38a939e..117b65c7 100644 --- a/projects/models.py +++ b/projects/models.py @@ -2,13 +2,13 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericRelation +from django.core.validators import MaxLengthValidator 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 users.models import CustomUser @@ -16,50 +16,48 @@ User = get_user_model() -class DefaultProjectCover(models.Model): +class AbstractDefaultProjectImage(models.Model): """ - Default cover model for projects, is chosen randomly at project creation - - Attributes: - image: A ForeignKey referencing the image of the cover. - datetime_created: A DateTimeField indicating date of creation. - datetime_updated: A DateTimeField indicating date of update. + Абстрактная модель для хранения изображений проекта по умолчанию. """ image = models.ForeignKey( UserFile, on_delete=models.CASCADE, - related_name="default_covers", null=True, blank=True, ) + datetime_created = models.DateTimeField(auto_now_add=True) + datetime_updated = models.DateTimeField(auto_now=True) - datetime_created = models.DateTimeField( - verbose_name="Дата создания", - null=False, - auto_now_add=True, - ) - datetime_updated = models.DateTimeField( - verbose_name="Дата изменения", - null=False, - auto_now=True, - ) + class Meta: + abstract = True @classmethod - def get_random_file(cls): - # FIXME: this is not efficient, but for ~10 default covers it should be ok - return cls.objects.order_by("?").first().image + def get_random_file(cls) -> Optional[UserFile]: + if not cls.objects.exists(): + return None + obj = cls.objects.order_by("?").first() + return obj.image if obj and obj.image else None @classmethod - def get_random_file_link(cls): - # FIXME: this is not efficient, but for ~10 default covers it should be ok - return cls.objects.order_by("?").first().image.link if cls.objects.order_by("?").first().image else None + def get_random_file_link(cls) -> Optional[str]: + file = cls.get_random_file() + return file.link if file else None + +class DefaultProjectCover(AbstractDefaultProjectImage): class Meta: verbose_name = "Обложка проекта" verbose_name_plural = "Обложки проектов" +class DefaultProjectAvatar(AbstractDefaultProjectImage): + class Meta: + verbose_name = "Аватарка проекта" + verbose_name_plural = "Аватарки проектов" + + class Project(models.Model): """ Project model @@ -85,9 +83,47 @@ class Project(models.Model): 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) + 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, + validators=[MaxLengthValidator(1000)], + verbose_name="Актуальность", + help_text="Почему проект важен (до 1000 симв.)", + ) + goal = models.CharField( + max_length=500, + blank=True, + null=True, + verbose_name="Цель", + help_text="Главная цель проекта (до 500 симв.)", + ) + problem = models.TextField( + blank=True, + null=True, + validators=[MaxLengthValidator(1000)], + verbose_name="Проблема", + help_text="Какую проблему решает проект (до 1000 симв.)", + ) + industry = models.ForeignKey( Industry, on_delete=models.SET_NULL, @@ -151,9 +187,13 @@ def __str__(self): return f"Project<{self.id}> - {self.name}" def save(self, *args, **kwargs): - """Set random cover image if `cover_image_address` blank.""" - if self.cover_image_address is None: + """Set random cover and avatar images if not provided.""" + if not self.cover_image_address: self.cover_image_address = DefaultProjectCover.get_random_file_link() + + if not self.image_address: + self.image_address = DefaultProjectAvatar.get_random_file_link() + super().save(*args, **kwargs) class Meta: @@ -222,6 +262,7 @@ class Collaborator(models.Model): user: A ForeignKey referencing the user who is collaborating in the project. project: A ForeignKey referencing the project the user is collaborating in. role: A CharField meaning the role the user is fulfilling in the project. + specialization: A CharField indicating the user's specialization within the project. datetime_created: A DateTimeField indicating date of creation. datetime_updated: A DateTimeField indicating date of update. """ @@ -234,6 +275,14 @@ class Collaborator(models.Model): ) project = models.ForeignKey(Project, models.CASCADE, verbose_name="Проект") role = models.CharField("Роль", max_length=1024, blank=True, null=True) + specialization = models.CharField( + "Специализация", + max_length=100, + blank=True, + null=True, + default=None, + help_text="Направления работы участника в рамках проекта", + ) datetime_created = models.DateTimeField( verbose_name="Дата создания", null=False, auto_now_add=True diff --git a/projects/serializers.py b/projects/serializers.py index a68cadb4..141e9f79 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -1,12 +1,13 @@ from django.contrib.auth import get_user_model -from rest_framework import serializers from django.core.cache import cache +from rest_framework import serializers + from core.serializers import SkillToObjectSerializer -from core.services import get_views_count, get_likes_count, is_fan +from core.services import get_likes_count, get_views_count, is_fan from core.utils import get_user_online_cache_key from files.serializers import UserFileSerializer from industries.models import Industry -from projects.models import Project, Achievement, Collaborator, ProjectNews +from projects.models import Achievement, Collaborator, Project, ProjectNews from projects.validators import validate_project from vacancy.serializers import ProjectVacancyListSerializer @@ -64,7 +65,6 @@ class Meta: class ProjectDetailSerializer(serializers.ModelSerializer): - achievements = AchievementListSerializer(many=True, read_only=True) cover = UserFileSerializer(required=False) collaborators = CollaboratorSerializer( @@ -76,6 +76,11 @@ class ProjectDetailSerializer(serializers.ModelSerializer): views_count = serializers.SerializerMethodField(method_name="count_views") links = serializers.SerializerMethodField() partner_programs_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) @classmethod def get_partner_programs_tags(cls, project): @@ -130,6 +135,11 @@ class Meta: "cover", "cover_image_address", "partner_programs_tags", + "track", + "direction", + "actuality", + "goal", + "problem", ] read_only_fields = [ "leader", diff --git a/projects/validators.py b/projects/validators.py index 8c20ca0e..f14e813c 100644 --- a/projects/validators.py +++ b/projects/validators.py @@ -4,8 +4,9 @@ def validate_project(data): if not data.get("draft"): error = {} + allowed_blank = {"image_address"} for key, value in data.items(): - if value == "" or value is None: + if (value == "" or value is None) and key not in allowed_blank: error[key] = "This field is required" if error: raise ValidationError(error) diff --git a/users/admin.py b/users/admin.py index 5e5a6ae7..c10356bd 100644 --- a/users/admin.py +++ b/users/admin.py @@ -124,6 +124,10 @@ class CustomUserAdmin(admin.ModelAdmin): "Важные даты", {"fields": ("last_login", "date_joined")}, ), + ( + "Студенты мосполитеха", + {"fields": ("is_mospolytech_student", "study_group")}, + ), ) list_display = ( @@ -296,10 +300,7 @@ def get_export_users_emails(self, users): users = ( CustomUser.objects.all() .select_related("v2_speciality") - .filter( - birthday__lte=date_limit_18, - birthday__gte=date_limit_22 - ) + .filter(birthday__lte=date_limit_18, birthday__gte=date_limit_22) ) # little_mans = users.filter(birthday__lte=date_limit_18) # big_mans = users.exclude(id__in=little_mans.values_list("id", flat=True)) @@ -312,13 +313,9 @@ def get_export_users_emails(self, users): response_data.append( [ user.first_name + " " + user.last_name, - (today.year - user.birthday.year) - if user.birthday.year - else None, + (today.year - user.birthday.year) if user.birthday.year else None, user.city, - user.v2_speciality - if user.v2_speciality - else user.speciality, + user.v2_speciality if user.v2_speciality else user.speciality, user.email, ] ) diff --git a/users/filters.py b/users/filters.py index 19855650..3e7fe47e 100644 --- a/users/filters.py +++ b/users/filters.py @@ -113,6 +113,11 @@ def filter_by_fullname(cls, queryset, name, value): skills__contains = filters.Filter(method="filter_by_skills") + is_mospolytech_student = filters.BooleanFilter( + field_name="is_mospolytech_student", + label="Студент Московского Политеха", + ) + class Meta: model = User fields = ( diff --git a/users/migrations/0053_customuser_is_mospolytech_student_and_more.py b/users/migrations/0053_customuser_is_mospolytech_student_and_more.py new file mode 100644 index 00000000..e873913a --- /dev/null +++ b/users/migrations/0053_customuser_is_mospolytech_student_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.11 on 2025-07-02 08:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0052_remove_customuser_organization"), + ] + + operations = [ + migrations.AddField( + model_name="customuser", + name="is_mospolytech_student", + field=models.BooleanField( + default=False, + help_text="Флаг, указывающий, является ли пользователь студентом МосПолитеха", + verbose_name="Студент Московского Политеха", + ), + ), + migrations.AddField( + model_name="customuser", + name="study_group", + field=models.CharField( + blank=True, + help_text="Краткое обозначение учебной группы (до 10 символов)", + max_length=10, + null=True, + verbose_name="Учебная группа", + ), + ), + ] diff --git a/users/models.py b/users/models.py index b21af20a..3f231842 100644 --- a/users/models.py +++ b/users/models.py @@ -1,25 +1,24 @@ +from django.contrib.auth.models import AbstractUser +from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError from django.db import models from django.db.models import QuerySet from django.utils import timezone -from django.core.exceptions import ValidationError -from django.contrib.auth.models import AbstractUser -from django.contrib.contenttypes.fields import GenericRelation - from django_stubs_ext.db.models import TypedModelMeta from users import constants from users.managers import ( CustomUserManager, - UserAchievementManager, LikesOnProjectManager, + UserAchievementManager, ) +from users.utils import normalize_user_phone from users.validators import ( user_birthday_validator, - user_name_validator, user_experience_years_range_validator, + user_name_validator, user_phone_number_validation, ) -from users.utils import normalize_user_phone def get_default_user_type(): @@ -102,7 +101,7 @@ class CustomUser(AbstractUser): null=True, blank=True, verbose_name="Номер телефона", - help_text="Пример: +7 XXX XX-XX-XX | +7XXXXXXXXX | +7 (XXX) XX-XX-XX" + help_text="Пример: +7 XXX XX-XX-XX | +7XXXXXXXXX | +7 (XXX) XX-XX-XX", ) v2_speciality = models.ForeignKey( on_delete=models.SET_NULL, @@ -138,7 +137,19 @@ class CustomUser(AbstractUser): blank=True, default=False, verbose_name="Временная мера для переноса навыка", - help_text="Yes если оба поля `v2_speciality` и `skills` есть, No если поля не перенеслись" + help_text="Yes если оба поля `v2_speciality` и `skills` есть, No если поля не перенеслись", + ) + is_mospolytech_student = models.BooleanField( + default=False, + verbose_name="Студент Московского Политеха", + help_text="Флаг, указывающий, является ли пользователь студентом МосПолитеха", + ) + study_group = models.CharField( + max_length=10, + null=True, + blank=True, + verbose_name="Учебная группа", + help_text="Краткое обозначение учебной группы (до 10 символов)", ) USERNAME_FIELD = "email" @@ -181,7 +192,9 @@ 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: @@ -192,7 +205,11 @@ def get_user_age(self) -> int: return None today = timezone.now() birthday = self.birthday - return today.year - birthday.year - ((today.month, today.day) < (birthday.month, birthday.day)) + return ( + today.year + - birthday.year + - ((today.month, today.day) < (birthday.month, birthday.day)) + ) def __str__(self) -> str: return f"User<{self.id}> - {self.first_name} {self.last_name}" @@ -442,6 +459,7 @@ class Meta(TypedModelMeta): class AbstractUserExperience(models.Model): """Abstact help model for user work|education experience.""" + organization_name = models.CharField( max_length=255, verbose_name="Наименование организации", @@ -469,9 +487,7 @@ class Meta: abstract = True def __str__(self) -> str: - return ( - f"id: {self.id} - ({self.user.first_name} {self.user.last_name} user_id: {self.user.id})" - ) + return f"id: {self.id} - ({self.user.first_name} {self.user.last_name} user_id: {self.user.id})" def clean(self) -> None: """Validate both years `entry` <`completion`""" @@ -541,6 +557,7 @@ class UserWorkExperience(AbstractUserExperience): entry_year: PositiveSmallIntegerField Year of admission. completion_year: PositiveSmallIntegerField Year of dismissal. """ + user = models.ForeignKey( to=CustomUser, on_delete=models.CASCADE, @@ -570,6 +587,7 @@ class UserLanguages(models.Model): language: CharField(choise) languages. language_level: CharField(choise) language level. """ + user = models.ForeignKey( to=CustomUser, on_delete=models.CASCADE, @@ -604,7 +622,9 @@ def clean(self) -> None: """ super().clean() user_languages = self.user.user_languages.values_list("language", flat=True) - if (self.language not in user_languages) and len(user_languages) == constants.USER_MAX_LANGUAGES_COUNT: + if (self.language not in user_languages) and len( + user_languages + ) == constants.USER_MAX_LANGUAGES_COUNT: raise ValidationError(constants.COUNT_LANGUAGES_VALIDATION_MESSAGE) def save(self, *args, **kwargs): @@ -612,9 +632,7 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) def __str__(self) -> str: - return ( - f"id: {self.id} - ({self.user.first_name} {self.user.last_name} user_id: {self.user.id})" - ) + return f"id: {self.id} - ({self.user.first_name} {self.user.last_name} user_id: {self.user.id})" class UserSkillConfirmation(models.Model): @@ -626,15 +644,12 @@ class UserSkillConfirmation(models.Model): confirmed_by: FK CustomUser. confirmed_at: DateTimeField. """ + skill_to_object = models.ForeignKey( - "core.SkillToObject", - on_delete=models.CASCADE, - related_name="confirmations" + "core.SkillToObject", on_delete=models.CASCADE, related_name="confirmations" ) confirmed_by = models.ForeignKey( - CustomUser, - on_delete=models.CASCADE, - related_name="skill_confirmations" + CustomUser, on_delete=models.CASCADE, related_name="skill_confirmations" ) confirmed_at = models.DateTimeField(auto_now_add=True) @@ -642,7 +657,7 @@ class Meta: constraints = [ models.UniqueConstraint( fields=["skill_to_object", "confirmed_by"], - name="unique_skill_confirmed_by" + name="unique_skill_confirmed_by", ) ] verbose_name = "Подтверждение навыка" diff --git a/users/serializers.py b/users/serializers.py index ed66d290..0e074f9e 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,23 +1,22 @@ from typing import Any -from django.db import transaction from django.contrib.contenttypes.models import ContentType -from django.forms.models import model_to_dict from django.core.cache import cache from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from django.forms.models import model_to_dict from rest_framework import serializers from rest_framework.exceptions import ValidationError +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from core.models import Skill, SkillToObject, Specialization, SpecializationCategory from core.serializers import SkillToObjectSerializer -from core.models import SpecializationCategory, Specialization, Skill, SkillToObject from core.services import get_views_count from core.utils import get_user_online_cache_key from partner_programs.models import PartnerProgram, PartnerProgramUserProfile -from projects.models import Project, Collaborator +from projects.models import Collaborator, Project from projects.validators import validate_project from users import constants -from users.utils import normalize_user_phone -from users.validators import specialization_exists_validator from users.models import ( CustomUser, Expert, @@ -26,12 +25,12 @@ Mentor, UserAchievement, UserEducation, - UserWorkExperience, - UserSkillConfirmation, UserLanguages, + UserSkillConfirmation, + UserWorkExperience, ) - -from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from users.utils import normalize_user_phone +from users.validators import specialization_exists_validator class AchievementListSerializer(serializers.ModelSerializer[UserAchievement]): @@ -451,6 +450,8 @@ class Meta: "projects", "programs", "dataset_migration_applied", + "is_mospolytech_student", # новое булево поле + "study_group", ] @transaction.atomic @@ -791,6 +792,7 @@ class Meta: "is_online", "birthday", "speciality", + "is_mospolytech_student", ] diff --git a/vacancy/admin.py b/vacancy/admin.py index de42048d..9245d43c 100644 --- a/vacancy/admin.py +++ b/vacancy/admin.py @@ -15,6 +15,7 @@ class VacancySkillToObjectInline(SkillToObjectInline): class VacancyAdmin(admin.ModelAdmin): list_display = [ "role", + "specialization", "description", "project", "is_active", @@ -24,7 +25,7 @@ class VacancyAdmin(admin.ModelAdmin): inlines = [ VacancySkillToObjectInline, ] - readonly_fields = ('datetime_closed',) + readonly_fields = ("datetime_closed",) list_display_links = ["role"] change_list_template = "vacancies/vacancies_change_list.html" diff --git a/vacancy/filters.py b/vacancy/filters.py index 6f21c8a3..17e4159c 100644 --- a/vacancy/filters.py +++ b/vacancy/filters.py @@ -47,50 +47,52 @@ def __init__(self, *args, **kwargs): self.data = dict(self.data) self.data["is_active"] = True - def filter_by_experience(self, queryset: QuerySet[Vacancy], name, value: list[str]) -> QuerySet[Vacancy]: - return ( - queryset - .filter(Q(required_experience__in=value) | Q(required_experience=None)) - .order_by(F("required_experience").asc(nulls_last=True)) + def filter_by_experience( + self, queryset: QuerySet[Vacancy], name, value: list[str] + ) -> QuerySet[Vacancy]: + return queryset.filter( + Q(required_experience__in=value) | Q(required_experience=None) + ).order_by(F("required_experience").asc(nulls_last=True)) + + def filter_by_schedule( + self, queryset: QuerySet[Vacancy], name, value: list[str] + ) -> QuerySet[Vacancy]: + return queryset.filter( + Q(work_schedule__in=value) | Q(work_schedule=None) + ).order_by(F("work_schedule").asc(nulls_last=True)) + + def filter_by_format( + self, queryset: QuerySet[Vacancy], name, value: list[str] + ) -> QuerySet[Vacancy]: + return queryset.filter(Q(work_format__in=value) | Q(work_format=None)).order_by( + F("work_format").asc(nulls_last=True) ) - def filter_by_schedule(self, queryset: QuerySet[Vacancy], name, value: list[str]) -> QuerySet[Vacancy]: - return ( - queryset - .filter(Q(work_schedule__in=value) | Q(work_schedule=None)) - .order_by(F("work_schedule").asc(nulls_last=True)) - ) - - def filter_by_format(self, queryset: QuerySet[Vacancy], name, value: list[str]) -> QuerySet[Vacancy]: - return ( - queryset - .filter(Q(work_format__in=value) | Q(work_format=None)) - .order_by(F("work_format").asc(nulls_last=True)) - ) - - def filter_by_salary_min(self, queryset: QuerySet[Vacancy], name, value: list[str]) -> QuerySet[Vacancy]: + def filter_by_salary_min( + self, queryset: QuerySet[Vacancy], name, value: list[str] + ) -> QuerySet[Vacancy]: try: min_salary = int(value[0]) - return ( - queryset - .filter(Q(salary__gte=min_salary) | Q(salary=None)) - .order_by(F("salary").asc(nulls_last=True)) + return queryset.filter(Q(salary__gte=min_salary) | Q(salary=None)).order_by( + F("salary").asc(nulls_last=True) ) except ValueError: return queryset - def filter_by_salary_max(self, queryset: QuerySet[Vacancy], name, value: list[str]) -> QuerySet[Vacancy]: + def filter_by_salary_max( + self, queryset: QuerySet[Vacancy], name, value: list[str] + ) -> QuerySet[Vacancy]: try: max_salary = int(value[0]) - return ( - queryset - .filter(Q(salary__lte=max_salary) | Q(salary=None)) - .order_by(F("salary").asc(nulls_last=True)) + return queryset.filter(Q(salary__lte=max_salary) | Q(salary=None)).order_by( + F("salary").asc(nulls_last=True) ) except ValueError: return queryset - def filter_by_role(self, queryset: QuerySet[Vacancy], name, value: list[str]) -> QuerySet[Vacancy]: + def filter_by_role( + self, queryset: QuerySet[Vacancy], name, value: list[str] + ) -> QuerySet[Vacancy]: if not value: return queryset return queryset.filter(role__icontains=value[0]) diff --git a/vacancy/migrations/0009_vacancy_specialization.py b/vacancy/migrations/0009_vacancy_specialization.py new file mode 100644 index 00000000..4cccf2d7 --- /dev/null +++ b/vacancy/migrations/0009_vacancy_specialization.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2025-07-10 06:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("vacancy", "0008_vacancy_salary_alter_vacancy_required_experience_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="vacancy", + name="specialization", + field=models.CharField( + blank=True, max_length=128, null=True, verbose_name="Специализация" + ), + ), + ] diff --git a/vacancy/models.py b/vacancy/models.py index e5ec2233..ba433c51 100644 --- a/vacancy/models.py +++ b/vacancy/models.py @@ -1,13 +1,13 @@ from django.contrib.contenttypes.fields import GenericRelation -from django.db import models from django.core.validators import MinValueValidator +from django.db import models from django.utils import timezone +from django_stubs_ext.db.models import TypedModelMeta from files.models import UserFile from projects.models import Project -from vacancy.constants import WorkExperience, WorkSchedule, WorkFormat +from vacancy.constants import WorkExperience, WorkFormat, WorkSchedule from vacancy.managers import VacancyManager, VacancyResponseManager -from django_stubs_ext.db.models import TypedModelMeta class Vacancy(models.Model): @@ -28,6 +28,9 @@ class Vacancy(models.Model): """ role = models.CharField(max_length=256, null=False) + specialization = models.CharField( + max_length=128, null=True, blank=True, verbose_name="Специализация" + ) required_skills = GenericRelation( "core.SkillToObject", related_query_name="vacancies", diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 56648c7b..3b4cf59d 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -10,7 +10,7 @@ from projects.models import Project from projects.validators import validate_project from users.serializers import UserDetailSerializer -from vacancy.constants import WorkExperience, WorkSchedule, WorkFormat +from vacancy.constants import WorkExperience, WorkFormat, WorkSchedule from vacancy.models import Vacancy, VacancyResponse User = get_user_model() @@ -51,8 +51,12 @@ def validate_work_format(self, value): def to_representation(self, instance): representation = super().to_representation(instance) - representation["required_experience"] = WorkExperience.to_display(instance.required_experience) - representation["work_schedule"] = WorkSchedule.to_display(instance.work_schedule) + representation["required_experience"] = WorkExperience.to_display( + instance.required_experience + ) + representation["work_schedule"] = WorkSchedule.to_display( + instance.work_schedule + ) representation["work_format"] = WorkFormat.to_display(instance.work_format) return representation @@ -78,6 +82,7 @@ class Meta: fields = [ "id", "role", + "specialization", "required_skills", "description", "project", @@ -112,6 +117,7 @@ class Meta: fields = [ "id", "role", + "specialization", "required_skills", "required_skills_ids", "description", @@ -139,6 +145,7 @@ class Meta: fields = [ "id", "role", + "specialization", "required_skills", "description", "is_active", @@ -192,7 +199,6 @@ class ProjectVacancyCreateListSerializer( AbstractVacancyEnumFields, RequiredSkillsWriteSerializerMixin[Vacancy], ): - def create(self, validated_data): project = validated_data["project"] if project.leader != self.context["request"].user: @@ -233,6 +239,7 @@ class Meta: fields = [ "id", "role", + "specialization", "required_skills", "required_skills_ids", "description", diff --git a/vacancy/tasks.py b/vacancy/tasks.py index 384a71f7..9ca02605 100644 --- a/vacancy/tasks.py +++ b/vacancy/tasks.py @@ -1,16 +1,16 @@ import datetime -from mailing.typing import EmailDataToPrepare, ContextDataDict, MailDataDict -from mailing.utils import send_mass_mail, prepare_mail_data +from mailing.typing import ContextDataDict, EmailDataToPrepare, MailDataDict +from mailing.utils import prepare_mail_data, send_mass_mail from procollab.celery import app from vacancy.mapping import ( CeleryEmailParams, + EmailParamsType, + MessageTypeEnum, create_text_for_email, - message_type_to_button_text, get_link, - MessageTypeEnum, + message_type_to_button_text, message_type_to_title, - EmailParamsType, ) from vacancy.models import Vacancy diff --git a/vacancy/views.py b/vacancy/views.py index bac8de90..78994bf9 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -70,7 +70,9 @@ def put(self, request, *args, **kwargs): return self.update(request, *args, **kwargs) -class VacancyResponseList(mixins.ListModelMixin, mixins.CreateModelMixin, GenericAPIView): +class VacancyResponseList( + mixins.ListModelMixin, mixins.CreateModelMixin, GenericAPIView +): permission_classes = [permissions.IsAuthenticatedOrReadOnly] serializer_class = VacancyResponseListSerializer @@ -145,7 +147,9 @@ def post(self, request, pk): role_add_as: str = vacancy.role # check if this person already has a collaborator role in this project - if Collaborator.objects.filter(project=project_add_in, user=user_to_add).exists(): + if Collaborator.objects.filter( + project=project_add_in, user=user_to_add + ).exists(): return Response( "You already work for this project, you can't accept a vacancy here", status=status.HTTP_400_BAD_REQUEST,