diff --git a/core/management/commands/migrate_education.py b/core/management/commands/migrate_education.py new file mode 100644 index 00000000..b48a469f --- /dev/null +++ b/core/management/commands/migrate_education.py @@ -0,0 +1,44 @@ +from django.db import transaction +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model + +from users.models import UserEducation + + +CustomUser = get_user_model() + + +class Command(BaseCommand): + """ + Use: python manage.py migrate_education. + """ + def handle(self, *args, **kwargs): + self.stdout.write("Start manual migration ...") + try: + total_user_migrate = migrate_organization_to_education() + self.stdout.write( + self.style.SUCCESS( + f"Manual migration complete, users migrated: {total_user_migrate}" + ) + ) + except Exception as e: + self.stderr.write( + self.style.ERROR(f"Migration failed: {str(e)}") + ) + + +@transaction.atomic +def migrate_organization_to_education() -> int: + """ + Migrate old field `organization` to new model `Education`. + Returns count migrated users. + """ + users_with_irganization = CustomUser.objects.exclude(organization=None).exclude(organization="") + UserEducation.objects.bulk_create([ + UserEducation( + user=user, + organization_name=user.organization, + ) + for user in users_with_irganization + ]) + return users_with_irganization.count() diff --git a/core/management/commands/reverse_migrate_education.py b/core/management/commands/reverse_migrate_education.py new file mode 100644 index 00000000..3791b1de --- /dev/null +++ b/core/management/commands/reverse_migrate_education.py @@ -0,0 +1,51 @@ +from django.db import transaction +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandParser + +from users.models import UserEducation + + +CustomUser = get_user_model() + + +class Command(BaseCommand): + """ + Use: python manage.py reverse_migrate_education. + """ + + def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument( + "--confirm", + action="store_true", + help="Confirm delete Users educations from UserEducation model" + ) + + def handle(self, *args, **kwargs): + confirm = kwargs["confirm"] + self.stdout.write(self.style.WARNING( + "You are about to DELETE ALL INSTANCES in the UserEducation model.")) + + if not confirm: + answer = input("Type 'yes' to continue, or 'no' to cancel: ").lower() + if answer != "yes": + self.stdout.write(self.style.ERROR("Manual migrations canceled.")) + return + + self.stdout.write("Starting manual migrations...") + + try: + deleted_instances = delete_all_instances_usereducation() + self.stdout.write(self.style.SUCCESS("Manual migrations completed successfully.")) + self.stdout.write(self.style.SUCCESS(f"Deleted: {deleted_instances}")) + except Exception as e: + self.stderr.write(self.style.ERROR(f"Manual migrations failed: {str(e)}")) + + +@transaction.atomic +def delete_all_instances_usereducation() -> int: + """ + Destroy all UserEducation instances. + """ + count = UserEducation.objects.count() + UserEducation.objects.all().delete() + return count diff --git a/users/admin.py b/users/admin.py index 2ada8d11..e84693b5 100644 --- a/users/admin.py +++ b/users/admin.py @@ -18,6 +18,7 @@ Expert, Investor, UserLink, + UserEducation, ) from core.admin import SkillToObjectInline @@ -25,6 +26,13 @@ admin.site.register(Permission) +class UserEducationInline(admin.TabularInline): + model = UserEducation + extra = 1 + verbose_name = "Образование" + verbose_name_plural = "Образования" + + @admin.register(CustomUser) class CustomUserAdmin(admin.ModelAdmin): fieldsets = ( @@ -68,7 +76,7 @@ class CustomUserAdmin(admin.ModelAdmin): "status", "city", "region", - "organization", + "organization", # TODO need to be removed in future. "speciality", "v2_speciality", "key_skills", @@ -124,6 +132,7 @@ class CustomUserAdmin(admin.ModelAdmin): inlines = [ SkillToObjectInline, + UserEducationInline, ] readonly_fields = ("ordering_score",) @@ -315,3 +324,10 @@ class UserLinkAdmin(admin.ModelAdmin): @admin.register(Expert) class ExpertAdmin(admin.ModelAdmin): list_display = ("id", "user") + + +@admin.register(UserEducation) +class UserEducationAdmin(admin.ModelAdmin): + list_display = ("id", "user", "organization_name", "entry_year") + list_display_links = ("id", "organization_name") + search_fields = ("user__first_name", "user__email") diff --git a/users/filters.py b/users/filters.py index 718a616d..f0fe2772 100644 --- a/users/filters.py +++ b/users/filters.py @@ -121,7 +121,7 @@ class Meta: "patronymic", "city", "region", - "organization", + "organization", # TODO need to be removed in future. "user_type", "speciality", ) diff --git a/users/migrations/0049_alter_customuser_key_skills_and_more.py b/users/migrations/0049_alter_customuser_key_skills_and_more.py new file mode 100644 index 00000000..5a848aee --- /dev/null +++ b/users/migrations/0049_alter_customuser_key_skills_and_more.py @@ -0,0 +1,97 @@ +# Generated by Django 4.2.11 on 2024-09-02 16:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import users.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0048_customuser_dataset_migration_applied"), + ] + + operations = [ + migrations.AlterField( + model_name="customuser", + name="key_skills", + field=models.CharField( + blank=True, + help_text="Устаревшее поле -> skills", + max_length=512, + null=True, + ), + ), + migrations.AlterField( + model_name="customuser", + name="organization", + field=models.CharField( + blank=True, + help_text="Устаревшее поле -> UserEducation", + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="customuser", + name="speciality", + field=models.CharField( + blank=True, + help_text="Устаревшее поле -> v2_speciality", + max_length=255, + null=True, + ), + ), + migrations.CreateModel( + name="UserEducation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "organization_name", + models.CharField( + max_length=255, verbose_name="Наименование организации" + ), + ), + ( + "description", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Краткое описание", + ), + ), + ( + "entry_year", + models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[users.validators.user_entry_year_education_validator], + verbose_name="Год поступления", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="education", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ], + options={ + "verbose_name": "Образование", + "verbose_name_plural": "Образование", + }, + ), + ] diff --git a/users/models.py b/users/models.py index 6f22a421..bcb7b999 100644 --- a/users/models.py +++ b/users/models.py @@ -19,7 +19,11 @@ UserAchievementManager, LikesOnProjectManager, ) -from users.validators import user_birthday_validator, user_name_validator +from users.validators import ( + user_birthday_validator, + user_name_validator, + user_entry_year_education_validator, +) def get_default_user_type(): @@ -71,7 +75,6 @@ class CustomUser(AbstractUser): choices=VERBOSE_USER_TYPES, default=get_default_user_type, ) - ordering_score = models.PositiveIntegerField( default=0, editable=False, @@ -79,14 +82,17 @@ class CustomUser(AbstractUser): patronymic = models.CharField( max_length=255, validators=[user_name_validator], null=True, blank=True ) + # TODO need to be removed in future `key_skills` -> `skills`. key_skills = models.CharField( - max_length=512, null=True, blank=True - ) # to be deprecated in future + max_length=512, + null=True, + blank=True, + help_text="Устаревшее поле -> skills", + ) skills = GenericRelation( "core.SkillToObject", related_query_name="users", ) - avatar = models.URLField(null=True, blank=True) birthday = models.DateField( validators=[user_birthday_validator], @@ -95,14 +101,26 @@ class CustomUser(AbstractUser): status = models.CharField(max_length=255, null=True, blank=True) region = models.CharField(max_length=255, null=True, blank=True) city = models.CharField(max_length=255, null=True, blank=True) - organization = models.CharField(max_length=255, null=True, blank=True) + # TODO need to be removed in future `organization` -> `education`. + organization = models.CharField( + max_length=255, + null=True, + blank=True, + help_text="Устаревшее поле -> UserEducation", + ) v2_speciality = models.ForeignKey( on_delete=models.SET_NULL, null=True, related_name="users", to="core.Specialization", ) - speciality = models.CharField(max_length=255, null=True, blank=True) # to be deprecated in future + # TODO need to be removed in future `speciality` -> `v2_speciality`. + speciality = models.CharField( + max_length=255, + null=True, + blank=True, + help_text="Устаревшее поле -> v2_speciality", + ) onboarding_stage = models.PositiveSmallIntegerField( null=True, blank=True, @@ -119,7 +137,8 @@ class CustomUser(AbstractUser): ) datetime_updated = models.DateTimeField(auto_now=True) datetime_created = models.DateTimeField(auto_now_add=True) - dataset_migration_applied = models.BooleanField( # To be deprecated in future. + # TODO need to be removed in future. + dataset_migration_applied = models.BooleanField( null=True, blank=True, default=False, @@ -157,7 +176,8 @@ def calculate_ordering_score(self) -> int: score += 4 if self.city: score += 4 - if self.organization: + # TODO need to be removed in future. + if self.organization or self.education.all().exists(): score += 6 if self.speciality: score += 7 @@ -411,3 +431,47 @@ class Meta(TypedModelMeta): verbose_name = "Ссылка пользователя" verbose_name_plural = "Ссылки пользователей" unique_together = ("user", "link") + + +class UserEducation(models.Model): + """ + User education model + + User education information. + + Attributes: + user: FK CustomUser. + organization_name: CharField Name of the organization. + description: CharField Organization Description. + entry_year: PositiveSmallIntegerField Year of admission. + """ + user = models.ForeignKey( + to=CustomUser, + on_delete=models.CASCADE, + related_name="education", + verbose_name="Пользователь", + ) + organization_name = models.CharField( + max_length=255, + verbose_name="Наименование организации", + ) + description = models.CharField( + max_length=255, + null=True, + blank=True, + verbose_name="Краткое описание", + ) + entry_year = models.PositiveSmallIntegerField( + null=True, + blank=True, + validators=[user_entry_year_education_validator], + verbose_name="Год поступления", + ) + + class Meta: + verbose_name = "Образование" + verbose_name_plural = "Образование" + + def __str__(self) -> str: + return (f"{self.user.first_name}: {self.organization_name} - " + f"{self.entry_year} (id {self.id})") diff --git a/users/serializers.py b/users/serializers.py index c7caf56b..53227efb 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,3 +1,4 @@ +from django.db import transaction from django.contrib.contenttypes.models import ContentType from django.forms.models import model_to_dict from rest_framework import serializers @@ -9,7 +10,7 @@ from core.utils import get_user_online_cache_key from projects.models import Project, Collaborator from projects.validators import validate_project -from .models import CustomUser, Expert, Investor, Member, Mentor, UserAchievement +from .models import CustomUser, Expert, Investor, Member, Mentor, UserAchievement, UserEducation from .validators import specialization_exists_validator from rest_framework_simplejwt.serializers import TokenObtainPairSerializer @@ -191,6 +192,13 @@ class UserSubscriptionDataSerializer(serializers.Serializer): is_autopay_allowed = serializers.BooleanField() +class UserEducationSerializer(serializers.ModelSerializer): + + class Meta: + model = UserEducation + fields = ["organization_name", "description", "entry_year"] + + class UserDetailSerializer( serializers.ModelSerializer[CustomUser], SkillsWriteSerializerMixin ): @@ -199,6 +207,7 @@ class UserDetailSerializer( expert = ExpertSerializer(required=False) mentor = MentorSerializer(required=False) achievements = AchievementListSerializer(required=False, many=True) + education = UserEducationSerializer(many=True) links = serializers.SerializerMethodField() is_online = serializers.SerializerMethodField() projects = serializers.SerializerMethodField() @@ -244,7 +253,8 @@ class Meta: "speciality", "v2_speciality", "v2_speciality_id", - "organization", + "organization", # TODO need to be removed in future. + "education", "about_me", "avatar", "links", @@ -262,6 +272,7 @@ class Meta: "dataset_migration_applied", ] + @transaction.atomic def update(self, instance, validated_data): IMMUTABLE_FIELDS = ("email", "is_active", "password") USER_TYPE_FIELDS = ("member", "investor", "expert", "mentor") @@ -308,6 +319,10 @@ def update(self, instance, validated_data): CustomUser.MENTOR: Mentor, } + education_data = validated_data.pop("education", None) + if education_data: + self._update_user_education(instance, education_data) + for attr, value in validated_data.items(): if attr in IMMUTABLE_FIELDS + USER_TYPE_FIELDS + RELATED_FIELDS: continue @@ -348,6 +363,23 @@ def update(self, instance, validated_data): return instance + @transaction.atomic + def _update_user_education(self, instance: CustomUser, data: list[dict]) -> None: + """ + Update user education. + `PUT`/ `PATCH` methods require full data about education. + """ + instance.education.all().delete() + UserEducation.objects.bulk_create([ + UserEducation( + user=instance, + organization_name=organization.get("organization_name"), + description=organization.get("description"), + entry_year=organization.get("entry_year"), + ) + for organization in data + ]) + class UserListSerializer( serializers.ModelSerializer[CustomUser], SkillsWriteSerializerMixin diff --git a/users/validators.py b/users/validators.py index 246cff45..1313eb2a 100644 --- a/users/validators.py +++ b/users/validators.py @@ -36,3 +36,11 @@ def specialization_exists_validator(pk: int): raise serializers.ValidationError( {"v2_speciality_id": "Specialization with given id does not exist"} ) + + +def user_entry_year_education_validator(value: int): + """Check education entry year.""" + if timezone.now().year < value: + raise ValidationError("Год поступления не может быть указан в бущуем") + if value < 1950: + raise ValidationError("Год поступления не быть раньше 1950")