diff --git a/core/admin.py b/core/admin.py index bb184111..c4d37f1e 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,5 +1,23 @@ from django.contrib import admin -from core.models import Like, View, Link, Specialization, SpecializationCategory +from django.contrib.contenttypes.admin import GenericStackedInline + +from core.models import ( + Like, + View, + Link, + Specialization, + SpecializationCategory, + Skill, + SkillCategory, + SkillToObject, +) + + +class SkillToObjectInline(GenericStackedInline): + model = SkillToObject + extra = 1 + verbose_name = "Навык" + verbose_name_plural = "Навыки" @admin.register(Like) @@ -20,6 +38,31 @@ class LinkAdmin(admin.ModelAdmin): list_display_links = ("id", "link", "content_type", "object_id", "content_object") +@admin.register(Skill) +class SkillAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + "category", + ) + list_display_links = ( + "id", + "name", + ) + + +@admin.register(SkillCategory) +class SkillCategoryAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + ) + list_display_links = ( + "id", + "name", + ) + + @admin.register(Specialization) class SpecializationAdmin(admin.ModelAdmin): list_display = ( diff --git a/core/filters.py b/core/filters.py new file mode 100644 index 00000000..233ef245 --- /dev/null +++ b/core/filters.py @@ -0,0 +1,11 @@ +from django_filters import rest_framework as filters + +from core.models import Skill + + +class SkillFilter(filters.FilterSet): + name__icontains = filters.Filter(field_name="name", lookup_expr="icontains") + + class Meta: + model = Skill + fields = ("name",) diff --git a/core/migrations/0011_auto_20240128_2214.py b/core/migrations/0011_auto_20240128_2214.py index 110710c0..4c55f884 100644 --- a/core/migrations/0011_auto_20240128_2214.py +++ b/core/migrations/0011_auto_20240128_2214.py @@ -137,17 +137,15 @@ def fill_specializations(apps, schema_editor): for spec, category in SPECIALIZATIONS: spec = spec.strip() category = category.strip() - if category not in categories_set: - category_obj = SpecializationCategory.objects.create(name=category) - category_obj.save() + if (category not in categories_set) and not SpecializationCategory.objects.filter(name=category).exists(): + SpecializationCategory.objects.create(name=category) categories_set.add(category) if spec not in specs_set: try: category_obj = SpecializationCategory.objects.get(name=category) specs_set.add(spec) - spec_obj = Specialization.objects.create(name=spec, category=category_obj) - spec_obj.save() + Specialization.objects.create(name=spec, category=category_obj) except Exception as error: print('An error during migration:', error) diff --git a/core/migrations/0013_add_skills_from_dataset.py b/core/migrations/0013_add_skills_from_dataset.py new file mode 100644 index 00000000..75ff601f --- /dev/null +++ b/core/migrations/0013_add_skills_from_dataset.py @@ -0,0 +1,103 @@ +# Generated by Django 4.2.3 on 2024-02-01 09:12 + +from django.db import migrations +from core.models import SkillCategory, Skill + +skills = {'Маркетинг': ['Ведение социальных сетей', 'Рилсмейкер', 'Копирайтер', 'Рерайтер', 'Email-кампании', + 'Аналитика рекламных кампаний', 'Аналитика влияния социальных сетей', 'Инфлюенс-маркетинг', + 'Аудиторская аналитика', 'Оптимизация для мобильных устройств', 'Таргетирование', + 'Мобильный маркетинг', 'Анализ и оптимизация контент-стратегий', 'Звукорежиссура', 'MS Office', + 'Копирайтинг', 'Создание контент-плана', 'SEO', 'SMM', 'Реклама', 'e-mail маркетинг', + 'Создание воронок', 'Digital маркетинг', 'SWOT', 'web Аналитика', 'PEST', '5W анализ', 'crm', + 'Холодные продажи', 'Продажи', 'Разработка стратегий продвижения', + 'Разработка стратегий выхода на рынок', 'Наружная реклама', 'Ценообразование'], + 'Нейросети': ['Midjourney', 'Word Embeddings', 'GANs', 'NLP', 'YOLO', 'DeepDream', 'LightGBM', 'U-Net', 'GPT', + 'Transfer Learning', 'bert', 'AutoML', 'Apache Spark', 'Apache Airflow', 'Kafka', 'ML', + 'catboost', 'pandas', 'scikit-learn', 'TensorRT', 'Tensorflow', 'Numpy', 'ETL', 'CV', 'PyTorch', + 'Keras', 'Matplotlib', 'Prophet', 'Hadoop', 'SARIMA', 'Spark', 'LSTM'], + 'Дизайн': ['Design thinking', 'Power point', 'Слайд-мейкинг', 'Motion design', 'AR', 'VR', 'VR/AR', + 'Дизайн игр', 'readymag', 'Видеомонтаж', 'Adobe', 'Презентации', 'Видео', 'Фотография', + 'Рилсмейкер', 'Прототипирование UI-UX', 'Работа с цветокоррекцией', 'UX проектирование', + 'Иллюстрации Procreate', 'Adobe After Effects', 'JTBD', 'Lightroom', 'QT', 'UX', 'UI', 'FIGMA', + 'Photoshop', 'Adobe illustrator', 'Inkscape', 'Adobe InDesign', 'GIMP', 'Graphic', 'Типографика', + 'Дизайн упаковки продукции', 'Дизайн мобильных приложений', 'Визуализация данных', 'PainNET', + 'Sketch', 'Paint3D', 'Создание презентации', 'Создание лендингов', 'CJM', 'Web-design', 'Canva', + 'WIX', 'Tilda', 'UserFlow', 'Blender', 'Magicavoxel'], + 'Soft skills': ['Вербальная и письменная коммуникация', 'Вовлеченность', 'Педагогика', + 'Разрешение конфликтов', 'Латеральное мышление', 'Быстрая обучаемость', 'Поиск информации', + 'Китайским (A2-B1)', 'Немецким А2', 'Основы бизнеса', 'Руководство', 'Нестандартное мышление', + 'Креативность', 'Эрудиция', 'Урбанизм', 'Внимательность', 'Английский язык (B2)', + 'Знание немецкого языка', 'Активность', 'Испанский', 'Финансовый учет', 'Самостоятельность', + 'Социальное предпринимательство', 'Этика', 'Сбор данных', 'Деловые переговоры', 'Research', + 'Целеустремленность', 'Ответственность', 'Коммуникабельность', 'Спикер', + 'Навык работы в команде', 'Критическое мышление', 'Решение проблем', + 'Адаптивность и гибкость', 'Умение обучаться и готовность к обучению', 'Организация времени', + 'Умение принимать решения', 'Эмоциональный интеллект', 'Стрессоустойчивость', 'Толерантность', + 'Эмпатия и умение понимать других', 'Управление конфликтами', 'Лидерские качества', + 'Тактичность', 'Мотивация', 'Самоорганизация', 'Ответственность', 'Дипломатичность', + 'Умение убеждать', 'Активный слушатель', 'Усидчивость', 'Пунктуальность', + 'Умение находить общий язык', 'Творческое мышление', 'Самоменеджмент', 'Настойчивость', + 'Автономность', 'Исполнительность', 'Скоропечатание', 'Восприятие критики', 'Красноречие', + 'Риторика', 'Умение работать с обратной связью', 'Профессиональная этика', 'Смекалка', + 'Дисциплина', 'Умение учиться на своих ошибках', 'Умение благодарить', + 'Оптимизм и позитивное мышление', 'Умение дать и принять комплимент', 'Навык делегирования', + 'Умение проводить встречи и презентации', 'Многозадачность', 'Публичные выступления', + 'Тайм-менеджмент', 'Лидерство', 'Работа в команде'], + 'Инженерия': ['1С', 'Написание протоколов', 'SaaS', 'LeoECS', 'KiCad', 'Ansys', 'Eagle', + 'Информационная безопасность', 'Построение интернет сетей', 'Lightroom', 'Unreal Engine 4', + 'Unreal Engine 5', 'Unity', 'Unity3D', 'Rockwell Automation', 'Paspberry Pi', 'Blender', + 'Развитие транспортных систем городов', '1С:Предприятие', '3ds Max', 'Revit', 'ArchiCAD', + 'Autodesk Maya', 'Autodesk AutoCAD', 'DesignSpark Mechanical', 'SketchUP', + 'Autodesk Fusion 360', 'Cinema 4D', 'Houdini', 'ZBrush', 'SculptGL', 'Wings 3D', 'FreeCAD', + 'Sweet Home 3D', 'LEGO Digital Designer', '3D Slash', 'Autodesk Tinkercad', + 'Autodesk Meshmixer', 'Autodesk ReCap Pro', 'Конструирование', 'Компас-3D', 'Solid Works', + 'Autodesk inventor', 'Робототехника', 'Работа с проектной документацией'], + 'Менеджмент': ['Комьюнити-менеджмент', 'Адаптивность продуктов и проектов', + 'Организация работы проектной группы', 'Аналитика', 'Мотивирование команды и отдельных людей', + 'Разработка стратегии', 'Глубинное интервью', 'Управленческие компетенции', 'QA', + 'Презентации проекта', 'Стратегия развития продукта', 'Формирование бэклога', + 'Ведение проекта', 'Анализ конкурентов', 'SOLID', 'Анализ рынка', 'Ideation', 'а', 'Use Case', + 'A/B тестирование', 'Unit-экономика', 'Диаграммы Ганта', 'Создание бизнес-модели', 'EduNet', + 'CustDev', 'ClickUp', 'Создание карты эмпатии', 'Trello', 'Jira', 'Confluence', 'XMind', + 'Bitrix', 'AmoCRM', 'Контроль команды', 'Координация команды', 'Организация команды', + 'Планирование задач', 'Разработка ТЗ', 'Мотивация команды', 'Управление процессами', + 'Управление задачами', 'Agile', 'SCRUM', 'Kanban', 'Введение документооборота', + 'Риск-менеджмент', 'Управление бюджетами и затратами', 'Ведение переговоров', + 'Расстановка приоритетов', 'Критическое мышление', 'Адаптивность', 'Lean', 'BPMN', + 'Управление изменениями в организации', 'R&D', 'Управление качеством', + 'Знание внедрения программ лояльности', 'Инновационное мышление и внедрение новых технологий', + 'Waterfall', 'Six Sigma', 'PRINCE2', 'Growth-hacking', 'Miro', 'HR'], + 'Back-end': ['Java', 'Docker', 'PHP', 'Ruby', 'Golang', 'Aiogram', 'Python', 'C++', 'C#', '1C', 'SQL', + 'MYSQL', 'PostgreSQL', 'Yii2', 'FastApi', 'ASP.NET', 'Scala', 'Swagger', 'Redis', 'Linux', + 'Django', 'Rust', 'Spring Framework', 'Flask', 'Express.js', 'Laravel', 'Ruby on Rails', + 'Fiber Framework', 'CakePHP', 'Play Framework', 'NestJS', 'GitHub', 'Flutter', 'GitHub Actions', + 'Django REST', 'Celery', 'Kotlin', 'PyTest', 'MongoDB', 'Postman', 'Dart', 'Apache Kafka', + 'RabbitMQ', 'PyQT', 'DRF', 'MsSQL Server', 'Git', 'ООП', 'Google Cloud Storage', 'Telegram боты', + 'NOSQL', 'grpc', 'vba', 'Directium', 'Kubernetes'], + 'Front-end': ['Agile', 'Bubble', 'Creatium', 'Glide', 'Гугл форма', 'XML', 'No code', 'Web components', + 'RxJS', 'Redux', 'D3.js', 'Node.js', 'Angular', 'Html', 'CSS', 'SCSS', 'JavaScript', 'NextJS', + 'VueJS', 'ReactJS', 'Tailwind', 'TypeScript', 'Nginx', 'JQuery', 'EmberJS', 'BackboneJS', + 'Elastic Search', 'Semantic-UI', 'BCPB', 'Foundation', 'Svelte', 'Preact', 'mjml', 'Swift']} + + +def fill_skills(apps, schema_editor): + for category_name, skills_list in skills.items(): + category = SkillCategory.objects.create(name=category_name) + + for skill_name in skills_list: + Skill.objects.create(name=skill_name, category=category) + + +def reverse(apps, schema_editor): + Skill.objects.all().delete() + SkillCategory.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0012_alter_specialization_options_and_more"), + ] + + operations = [ + migrations.RunPython(fill_skills, reverse_code=reverse), + ] diff --git a/core/migrations/0014_migrate_key_skills_to_skills.py b/core/migrations/0014_migrate_key_skills_to_skills.py new file mode 100644 index 00000000..e1773a90 --- /dev/null +++ b/core/migrations/0014_migrate_key_skills_to_skills.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.3 on 2024-02-01 09:34 +from django.contrib.contenttypes.models import ContentType +from django.db import migrations +from core.models import Skill, SkillToObject +from users.models import CustomUser + + +def migrate_key_skills_to_skills(apps, schema_editor): + for user in CustomUser.objects.all(): + if user.key_skills: + for skill_name in user.key_skills.lower().split(','): + skill_name = skill_name.strip() + skill = Skill.objects.filter(name__iexact=skill_name).first() + if skill: + SkillToObject.objects.get_or_create( + skill=skill, content_type=ContentType.objects.get_for_model(CustomUser), object_id=user.id + ) + + +def reverse(apps, schema_editor): + SkillToObject.objects.filter(content_type=ContentType.objects.get_for_model(CustomUser)).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ('contenttypes', '0001_initial'), + ("core", "0013_add_skills_from_dataset"), + ("users", "0044_auto_20240128_2236"), + ] + + operations = [ + migrations.RunPython(migrate_key_skills_to_skills, reverse_code=reverse), + ] diff --git a/core/models.py b/core/models.py index 7a9e005a..221a1a60 100644 --- a/core/models.py +++ b/core/models.py @@ -108,6 +108,9 @@ class SkillCategory(models.Model): name = models.CharField(max_length=256, null=False) + def __str__(self): + return self.name + class Meta: verbose_name = "Категория навыка" verbose_name_plural = "Категории навыков" @@ -126,10 +129,16 @@ class Skill(models.Model): related_name="skills", ) + def __str__(self): + return self.name + + def __repr__(self): + return f"Skill" + class Meta: verbose_name = "Навык" verbose_name_plural = "Навыки" - ordering = ["category", "name"] + ordering = ["id", "category", "name"] class SkillToObject(models.Model): @@ -151,6 +160,10 @@ class SkillToObject(models.Model): object_id = models.PositiveIntegerField() content_object = GenericForeignKey("content_type", "object_id") + class Meta: + verbose_name = "Ссылка на навык" + verbose_name_plural = "Ссылки на навыки" + class SpecializationCategory(models.Model): name = models.TextField() diff --git a/core/serializers.py b/core/serializers.py index c93960cb..6fff7685 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from .models import SkillToObject, SkillCategory, Skill + class SetLikedSerializer(serializers.Serializer): is_liked = serializers.BooleanField() @@ -7,3 +9,38 @@ class SetLikedSerializer(serializers.Serializer): class SetViewedSerializer(serializers.Serializer): is_viewed = serializers.BooleanField() + + +class SkillCategorySerializer(serializers.ModelSerializer[SkillCategory]): + class Meta: + model = SkillCategory + fields = [ + "id", + "name", + ] + + +class SkillSerializer(serializers.ModelSerializer): + category = SkillCategorySerializer() + + class Meta: + model = Skill + fields = ["id", "name", "category"] + + +class SkillToObjectSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(source="skill.id") + name = serializers.CharField(source="skill.name") + category = SkillCategorySerializer(source="skill.category") + + class Meta: + model = SkillToObject + fields = ["id", "name", "category"] + + +class SkillsSerializer(serializers.ModelSerializer[SkillCategory]): + skills = SkillSerializer(many=True) + + class Meta: + model = SkillCategory + fields = ["id", "name", "skills"] diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 00000000..86599c86 --- /dev/null +++ b/core/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from core.views import ( + SkillsNestedView, + SkillsInlineView, +) + +app_name = "core" + +urlpatterns = [ + path("skills/nested/", SkillsNestedView.as_view()), + path("skills/inline/", SkillsInlineView.as_view()), +] diff --git a/core/views.py b/core/views.py new file mode 100644 index 00000000..2913fd6d --- /dev/null +++ b/core/views.py @@ -0,0 +1,34 @@ +from django_filters import rest_framework as filters +from rest_framework import status +from rest_framework.generics import ( + GenericAPIView, + ListAPIView, +) +from rest_framework.response import Response + +from core.filters import SkillFilter +from core.models import SkillCategory, Skill +from core.pagination import Pagination +from core.serializers import ( + SkillsSerializer, + SkillSerializer, +) + + +class SkillsNestedView(GenericAPIView): + serializer_class = SkillsSerializer + queryset = SkillCategory.objects.all() + + def get(self, request): + data = self.serializer_class(self.get_queryset(), many=True).data + return Response(status=status.HTTP_200_OK, data=data) + + +class SkillsInlineView(ListAPIView): + serializer_class = SkillSerializer + pagination_class = Pagination + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = SkillFilter + + def get_queryset(self): + return Skill.objects.all() diff --git a/events/serializers.py b/events/serializers.py index ec43f6d8..47094d23 100644 --- a/events/serializers.py +++ b/events/serializers.py @@ -3,9 +3,10 @@ from rest_framework import serializers from taggit.serializers import TaggitSerializer, TagListSerializerField +from core.serializers import SkillToObjectSerializer from core.utils import get_user_online_cache_key from events.models import Event -from users.serializers import MemberSerializer, KeySkillsField +from users.serializers import MemberSerializer USER = get_user_model() @@ -55,7 +56,7 @@ class Meta: class RegisteredUserListSerializer(serializers.ModelSerializer): member = MemberSerializer(required=False) - key_skills = KeySkillsField(required=False) + skills = SkillToObjectSerializer(many=True, read_only=True) is_online = serializers.SerializerMethodField() @classmethod @@ -72,7 +73,7 @@ class Meta: "first_name", "last_name", "patronymic", - "key_skills", + "skills", "avatar", "speciality", "birthday", diff --git a/procollab/urls.py b/procollab/urls.py index 25c9d7de..4637c30a 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -42,6 +42,7 @@ path("news/", include("news.urls", namespace="news")), path("projects/", include("projects.urls", namespace="projects")), path("vacancies/", include("vacancy.urls", namespace="vacancies")), + path("core/", include("core.urls", namespace="core")), path("invites/", include("invites.urls", namespace="invites")), path("auth/", include(("users.urls", "users"), namespace="users")), path("chats/", include("chats.urls", namespace="chats")), diff --git a/projects/helpers.py b/projects/helpers.py index 28d39a84..3b37a20c 100644 --- a/projects/helpers.py +++ b/projects/helpers.py @@ -11,21 +11,21 @@ def get_recommended_users(project: Project) -> list[User]: """ - Searches for users by matching their key_skills and vacancies required_skills + Searches for users by matching their skills and vacancies required_skills """ - # fixme: store key_skills and required_skills more convenient, not just as a string all_needed_skills = set() for vacancy in project.vacancies.all(): - all_needed_skills.update(set(vacancy.required_skills.lower().split(","))) + all_needed_skills.update(set(vacancy.get_required_skills())) recommended_users = [] for user in User.objects.get_members(): - if user == project.leader or not user.key_skills: + if user == project.leader or user.skills_count < 1: continue - skills = set(user.key_skills.lower().split(",")) - if skills.intersection(all_needed_skills): + user_skills = set(user.get_skills()) + + if all_needed_skills & user_skills: recommended_users.append(user) # get some random users diff --git a/projects/serializers.py b/projects/serializers.py index 0d7ca62f..fb6f159c 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -1,7 +1,7 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from django.core.cache import cache -from core.fields import CustomListField +from core.serializers import SkillToObjectSerializer from core.services import get_views_count, get_likes_count, is_fan from core.utils import get_user_online_cache_key from files.serializers import UserFileSerializer @@ -41,7 +41,7 @@ class CollaboratorSerializer(serializers.ModelSerializer): first_name = serializers.CharField(source="user.first_name") last_name = serializers.CharField(source="user.last_name") avatar = serializers.CharField(source="user.avatar") - key_skills = CustomListField(child=serializers.CharField(), source="user.key_skills") + skills = SkillToObjectSerializer(many=True, read_only=True, source="user.skills") class Meta: model = Collaborator @@ -50,7 +50,7 @@ class Meta: "first_name", "last_name", "role", - "key_skills", + "skills", "avatar", ] diff --git a/users/admin.py b/users/admin.py index 49ecba7c..3d6e1ec5 100644 --- a/users/admin.py +++ b/users/admin.py @@ -15,6 +15,8 @@ UserLink, ) +from core.admin import SkillToObjectInline + @admin.register(CustomUser) class CustomUserAdmin(admin.ModelAdmin): @@ -111,6 +113,10 @@ class CustomUserAdmin(admin.ModelAdmin): "v2_speciality__name", ) + inlines = [ + SkillToObjectInline, + ] + readonly_fields = ("ordering_score",) change_form_template = "users/admin/users_change_form.html" diff --git a/users/filters.py b/users/filters.py index 3633e99b..718a616d 100644 --- a/users/filters.py +++ b/users/filters.py @@ -1,4 +1,5 @@ from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django_filters import rest_framework as filters @@ -19,11 +20,10 @@ class UserFilter(filters.FilterSet): Parameters to filter by: first_name (str), last_name (str), patronymic (str), city (str), region (str), organization (str), about_me__contains (str), - key_skills__contains (str), useful_to_project__contains (str) + useful_to_project__contains (str) Examples: ?first_name=test equals to .filter(first_name='test') - ?key_skills__contains=yawning equals to .filter(key_skills__containing='yawning') ?user_type=1 equals to .filter(user_type=1) To check what user_types there are & what id they are, see CustomUser.VERBOSE_USER_TYPES @@ -47,6 +47,25 @@ def filter_by_partner_program(cls, queryset, name, value): except PartnerProgram.DoesNotExist: return User.objects.none() + @classmethod + def filter_by_skills(cls, queryset, name, skills_string): + skill_names = [ + skill.strip() for skill in skills_string.split(",") if skill.strip() + ] + + user_content_type = ContentType.objects.get_for_model(queryset.model) + + skills_filter = Q() + for skill_name in skill_names: + skills_filter |= Q( + skills__skill__name__icontains=skill_name, + skills__content_type=user_content_type, + ) + + filtered_queryset = queryset.filter(skills_filter).distinct() + + return filtered_queryset + @classmethod def filter_age__gte(cls, queryset, name, value): return filter_age(queryset, value, MAX_AGE_VALUE) @@ -74,9 +93,6 @@ def filter_by_fullname(cls, queryset, name, value): ) about_me__contains = filters.Filter(field_name="about_me", lookup_expr="contains") - key_skills__icontains = filters.Filter( - field_name="key_skills", lookup_expr="icontains" - ) speciality__icontains = filters.Filter( field_name="speciality", lookup_expr="icontains" ) @@ -95,6 +111,8 @@ def filter_by_fullname(cls, queryset, name, value): age__gte = filters.Filter(method="filter_age__gte") age__lte = filters.Filter(method="filter_age__lte") + skills__contains = filters.Filter(method="filter_by_skills") + class Meta: model = User fields = ( diff --git a/users/models.py b/users/models.py index c28f950f..6732a840 100644 --- a/users/models.py +++ b/users/models.py @@ -2,6 +2,7 @@ from django.db import models from django.db.models import QuerySet from django_stubs_ext.db.models import TypedModelMeta +from django.contrib.contenttypes.fields import GenericRelation from users.constants import ( ADMIN, @@ -76,7 +77,14 @@ class CustomUser(AbstractUser): patronymic = models.CharField( max_length=255, validators=[user_name_validator], null=True, blank=True ) - key_skills = models.CharField(max_length=512, null=True, blank=True) + key_skills = models.CharField( + max_length=512, null=True, blank=True + ) # to be deprecated in future + skills = GenericRelation( + "core.SkillToObject", + related_query_name="users", + ) + avatar = models.URLField(null=True, blank=True) birthday = models.DateField( validators=[user_birthday_validator], @@ -115,6 +123,13 @@ class CustomUser(AbstractUser): objects = CustomUserManager() + @property + def skills_count(self): + return self.skills.count() + + def get_skills(self): + return [sto.skill for sto in self.skills.all()] + def calculate_ordering_score(self) -> int: """ Calculate ordering score of the user, e.g. how full their profile is. @@ -125,7 +140,7 @@ def calculate_ordering_score(self) -> int: score = 0 if self.avatar: score += 10 - if self.key_skills: + if self.skills_count > 0: score += 7 if self.about_me: score += 6 @@ -145,9 +160,6 @@ def get_project_chats(self) -> QuerySet: user_project_ids = self.collaborations.all().values_list("project_id", flat=True) return ProjectChat.objects.filter(project__in=user_project_ids) - def get_key_skills(self) -> list[str]: - return [skill.strip() for skill in self.key_skills.split(",") if skill.strip()] - def get_full_name(self) -> str: return f"{self.first_name} {self.last_name}" @@ -157,7 +169,7 @@ def __str__(self) -> str: class Meta(TypedModelMeta): verbose_name = "Пользователь" verbose_name_plural = "Пользователи" - # order by count of fields inputted, like avatar, key_skills, about_me, etc. + # order by count of fields inputted, like avatar, skills, about_me, etc. # first show users with all fields inputted, then with 1 field inputted, etc. ordering = ["-ordering_score", "id"] diff --git a/users/serializers.py b/users/serializers.py index 64e7b48e..401ec7d4 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,8 +1,10 @@ +from django.contrib.contenttypes.models import ContentType from django.forms.models import model_to_dict from rest_framework import serializers from django.core.cache import cache -from core.models import SpecializationCategory, Specialization +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 projects.models import Project, Collaborator @@ -18,14 +20,6 @@ class Meta: ref_name = "Users" -class KeySkillsField(serializers.Field): - def to_representation(self, value): - return [skill.strip() for skill in value.split(",") if skill.strip()] - - def to_internal_value(self, data): - return ",".join(data) - - class CustomListField(serializers.ListField): # костыль def to_representation(self, data): @@ -98,6 +92,16 @@ class Meta: fields = ["id", "name", "specializations"] +class SkillsSerializerMixin(serializers.Serializer): + skills = SkillToObjectSerializer(many=True, read_only=True) + + +class SkillsWriteSerializerMixin(SkillsSerializerMixin): + skills_ids = serializers.ListField( + child=serializers.IntegerField(), write_only=True, required=False + ) + + class UserProjectsSerializer(serializers.ModelSerializer[Project]): short_description = serializers.SerializerMethodField() views_count = serializers.SerializerMethodField() @@ -168,13 +172,14 @@ class Meta: read_only_fields = ["leader", "collaborator"] -class UserDetailSerializer(serializers.ModelSerializer[CustomUser]): +class UserDetailSerializer( + serializers.ModelSerializer[CustomUser], SkillsWriteSerializerMixin +): member = MemberSerializer(required=False) investor = InvestorSerializer(required=False) expert = ExpertSerializer(required=False) mentor = MentorSerializer(required=False) achievements = AchievementListSerializer(required=False, many=True) - key_skills = KeySkillsField(required=False) links = serializers.SerializerMethodField() is_online = serializers.SerializerMethodField() projects = serializers.SerializerMethodField() @@ -213,7 +218,8 @@ class Meta: "first_name", "last_name", "patronymic", - "key_skills", + "skills", + "skills_ids", "birthday", "speciality", "v2_speciality", @@ -302,13 +308,30 @@ def update(self, instance, validated_data): new_user_type.save() setattr(instance, attr, value) + if attr == "skills_ids": + instance.skills.all().delete() + + for skill_id in value: + try: + skill = Skill.objects.get(id=skill_id) + except Skill.DoesNotExist: + raise serializers.ValidationError("Skill does not exist") + + SkillToObject.objects.create( + skill=skill, + content_type=ContentType.objects.get_for_model(CustomUser), + object_id=instance.id, + ) + instance.save() + return instance -class UserListSerializer(serializers.ModelSerializer[CustomUser]): +class UserListSerializer( + serializers.ModelSerializer[CustomUser], SkillsWriteSerializerMixin +): member = MemberSerializer(required=False) - key_skills = KeySkillsField(required=False) is_online = serializers.SerializerMethodField() def get_is_online(self, user: CustomUser) -> bool: @@ -325,6 +348,18 @@ def create(self, validated_data) -> CustomUser: user.set_password(validated_data["password"]) user.save() + for skill_id in validated_data.get("skills_ids", []): + try: + skill = Skill.objects.get(id=skill_id) + except Skill.DoesNotExist: + raise serializers.ValidationError("Skill does not exist") + + SkillToObject.objects.create( + skill=skill, + content_type=ContentType.objects.get_for_model(CustomUser), + object_id=user.id, + ) + return user class Meta: @@ -336,7 +371,8 @@ class Meta: "first_name", "last_name", "patronymic", - "key_skills", + "skills", + "skills_ids", "avatar", "speciality", "birthday", @@ -354,7 +390,7 @@ class Meta: } -class UserFeedSerializer(serializers.ModelSerializer): +class UserFeedSerializer(serializers.ModelSerializer, SkillsSerializerMixin): class Meta: model = CustomUser fields = [ @@ -364,7 +400,7 @@ class Meta: "first_name", "last_name", "patronymic", - "key_skills", + "skills", "speciality", ] diff --git a/vacancy/admin.py b/vacancy/admin.py index d76d5db1..5e97aa2f 100644 --- a/vacancy/admin.py +++ b/vacancy/admin.py @@ -1,19 +1,26 @@ from django.contrib import admin +from core.admin import SkillToObjectInline from vacancy.models import Vacancy, VacancyResponse +class VacancySkillToObjectInline(SkillToObjectInline): + verbose_name_plural = "Необходимые навыки" + + @admin.register(Vacancy) class VacancyAdmin(admin.ModelAdmin): list_display = [ "role", - "required_skills", "description", "project", "is_active", "datetime_created", "datetime_updated", ] + inlines = [ + VacancySkillToObjectInline, + ] list_display_links = ["role"] diff --git a/vacancy/migrations/0002_rename_required_skills_vacancy_required_skills_old.py b/vacancy/migrations/0002_rename_required_skills_vacancy_required_skills_old.py new file mode 100644 index 00000000..d9fa9c4a --- /dev/null +++ b/vacancy/migrations/0002_rename_required_skills_vacancy_required_skills_old.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.3 on 2024-03-02 22:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("vacancy", "0001_initial"), + ] + + operations = [ + migrations.RenameField( + model_name="vacancy", + old_name="required_skills", + new_name="required_skills_old", + ), + ] diff --git a/vacancy/migrations/0003_migrate_old_required_skills_to_new.py b/vacancy/migrations/0003_migrate_old_required_skills_to_new.py new file mode 100644 index 00000000..613bda04 --- /dev/null +++ b/vacancy/migrations/0003_migrate_old_required_skills_to_new.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.3 on 2024-03-02 22:32 +from django.contrib.contenttypes.models import ContentType +from django.db import migrations +from core.models import Skill, SkillToObject +from vacancy.models import Vacancy + + +def migrate_required_skills(apps, schema_editor): + for vacancy in Vacancy.objects.all(): + if vacancy.required_skills_old: + for skill_name in vacancy.required_skills_old.lower().split(','): + skill_name = skill_name.strip() + skill = Skill.objects.filter(name__iexact=skill_name).first() + if skill: + SkillToObject.objects.get_or_create( + skill=skill, content_type=ContentType.objects.get_for_model(Vacancy), object_id=vacancy.id + ) + + +def reverse(apps, schema_editor): + SkillToObject.objects.filter(content_type=ContentType.objects.get_for_model(Vacancy)).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ('contenttypes', '0001_initial'), + ("vacancy", "0002_rename_required_skills_vacancy_required_skills_old"), + ("core", "0013_add_skills_from_dataset") + ] + + operations = [ + migrations.RunPython(migrate_required_skills, reverse_code=reverse), + ] diff --git a/vacancy/models.py b/vacancy/models.py index 1932d75f..3c6e9b76 100644 --- a/vacancy/models.py +++ b/vacancy/models.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from projects.models import Project @@ -10,7 +11,8 @@ class Vacancy(models.Model): Attributes: role: A CharField title of the vacancy. - required_skills: A CharField required skills for the vacancy. + required_skills_old: A CharField required skills for the vacancy (to be deprecated). + required_skills: A GenericRelation of required skills for the vacancy. description: A TextField description of the vacancy. project: A ForeignKey referring to the Company model. is_active: A boolean indicating if Vacancy is active. @@ -19,7 +21,11 @@ class Vacancy(models.Model): """ role = models.CharField(max_length=256, null=False) - required_skills = models.TextField(blank=True) + required_skills_old = models.TextField(blank=True) # to be deprecated in future + required_skills = GenericRelation( + "core.SkillToObject", + related_query_name="vacancies", + ) description = models.TextField(blank=True) project = models.ForeignKey( Project, @@ -39,6 +45,12 @@ class Vacancy(models.Model): objects = VacancyManager() + def get_required_skills(self): + required_skills = [] + for sto in self.required_skills.all(): + required_skills.append(sto.skill) + return required_skills + def __str__(self): return f"Vacancy<{self.id}> - {self.role}" diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 18f1f1d2..2b1007cf 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -1,15 +1,24 @@ from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from rest_framework import serializers +from core.models import Skill, SkillToObject +from core.serializers import SkillToObjectSerializer from projects.models import Project -from users.serializers import UserDetailSerializer, CustomListField +from users.serializers import UserDetailSerializer from vacancy.models import Vacancy, VacancyResponse User = get_user_model() class RequiredSkillsSerializerMixin(serializers.Serializer): - required_skills = CustomListField(child=serializers.CharField()) + required_skills = SkillToObjectSerializer(many=True, read_only=True) + + +class RequiredSkillsWriteSerializerMixin(RequiredSkillsSerializerMixin): + required_skills_ids = serializers.ListField( + child=serializers.IntegerField(), write_only=True, required=False + ) class ProjectForVacancySerializer(serializers.ModelSerializer): @@ -23,7 +32,9 @@ class Meta: ] -class VacancyDetailSerializer(serializers.ModelSerializer, RequiredSkillsSerializerMixin): +class VacancyDetailSerializer( + serializers.ModelSerializer, RequiredSkillsWriteSerializerMixin +): project = ProjectForVacancySerializer(many=False, read_only=True) class Meta: @@ -32,6 +43,7 @@ class Meta: "id", "role", "required_skills", + "required_skills_ids", "description", "project", "is_active", @@ -72,14 +84,40 @@ class Meta: class ProjectVacancyCreateListSerializer( - serializers.ModelSerializer, RequiredSkillsSerializerMixin + serializers.ModelSerializer, RequiredSkillsWriteSerializerMixin ): + def create(self, validated_data): + project = validated_data["project"] + if project.leader != self.context["request"].user: + raise serializers.ValidationError("You are not the leader of the project") + + required_skills_ids = validated_data.pop("required_skills_ids") + + vacancy = Vacancy.objects.create(**validated_data) + + for skill_id in required_skills_ids: + try: + skill = Skill.objects.get(id=skill_id) + except Skill.DoesNotExist: + raise serializers.ValidationError( + f"Skill with id {skill_id} does not exist" + ) + + SkillToObject.objects.create( + skill=skill, + content_type=ContentType.objects.get_for_model(Vacancy), + object_id=vacancy.id, + ) + + return vacancy + class Meta: model = Vacancy fields = [ "id", "role", "required_skills", + "required_skills_ids", "description", "project", "is_active", diff --git a/vacancy/tests.py b/vacancy/tests.py index 79bb43d7..b15e2928 100644 --- a/vacancy/tests.py +++ b/vacancy/tests.py @@ -1,3 +1,5 @@ +from collections import OrderedDict + from django.test import TestCase from rest_framework.test import APIRequestFactory, force_authenticate @@ -23,7 +25,7 @@ def setUp(self): self.user_project_owner = self.user_create() self.vacancy_create_data = { "role": "Test", - "required_skills": ["Test"], + "required_skills_ids": [1, 15], "description": "Test", "is_active": True, "project": Project.objects.create( @@ -42,7 +44,25 @@ def test_vacancy_creation(self): self.assertEqual(response.status_code, 201) self.assertEqual(response.data["role"], "Test") - self.assertEqual(response.data["required_skills"], ["Test"]) + self.assertEqual( + response.data["required_skills"], + [ + OrderedDict( + [ + ("id", 1), + ("name", "Ведение социальных сетей"), + ("category", OrderedDict([("id", 1), ("name", "Маркетинг")])), + ] + ), + OrderedDict( + [ + ("id", 15), + ("name", "MS Office"), + ("category", OrderedDict([("id", 1), ("name", "Маркетинг")])), + ] + ), + ], + ) self.assertEqual(response.data["description"], "Test") self.assertEqual(response.data["is_active"], True) self.assertEqual(response.data["project"], self.vacancy_create_data["project"]) diff --git a/vacancy/views.py b/vacancy/views.py index 8b6936af..6adacbee 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -1,9 +1,11 @@ +from django.contrib.contenttypes.models import ContentType from django_filters import rest_framework as filters from rest_framework import generics, mixins, permissions, status from rest_framework.generics import GenericAPIView from rest_framework.response import Response -from projects.models import Collaborator +from core.models import Skill, SkillToObject +from projects.models import Collaborator from vacancy.filters import VacancyFilter from vacancy.models import Vacancy, VacancyResponse from vacancy.permissions import ( @@ -27,33 +29,63 @@ class VacancyList(generics.ListCreateAPIView): filter_backends = (filters.DjangoFilterBackend,) filterset_class = VacancyFilter - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - if serializer.validated_data["project"].leader != request.user: - # additional check that the user is the vacancy's project's leader - return Response(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) - class VacancyDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Vacancy.objects.get_vacancy_for_detail_view() serializer_class = VacancyDetailSerializer permission_classes = [IsVacancyProjectLeader] + def patch(self, request, *args, **kwargs): + vacancy = self.get_object() + if "required_skills_ids" in request.data: + vacancy.required_skills.all().delete() + + for skill_id in request.data["required_skills_ids"]: + try: + skill = Skill.objects.get(id=skill_id) + except Skill.DoesNotExist: + return Response( + f"Skill with id {skill_id} does not exist", + status=status.HTTP_400_BAD_REQUEST, + ) + + SkillToObject.objects.create( + skill=skill, + content_type=ContentType.objects.get_for_model(Vacancy), + object_id=vacancy.id, + ) + + return self.partial_update(request, *args, **kwargs) + def put(self, request, *args, **kwargs): """updating the vacancy""" + vacancy = self.get_object() + if not request.data.get("is_active"): # automatically declining every vacancy response if the vacancy is not active - vacancy = self.get_object() vacancy_requests = VacancyResponse.objects.filter( vacancy=vacancy, is_approved=None ) - for vacancy_request in vacancy_requests: - vacancy_request.is_approved = False - vacancy_request.save() + vacancy_requests.update(is_approved=False) + + if request.data.get("required_skills_ids"): + vacancy.required_skills.all().delete() + + for skill_id in request.data["required_skills_ids"]: + try: + skill = Skill.objects.get(id=skill_id) + except Skill.DoesNotExist: + return Response( + f"Skill with id {skill_id} does not exist", + status=status.HTTP_400_BAD_REQUEST, + ) + + SkillToObject.objects.get_or_create( + skill=skill, + content_type=ContentType.objects.get_for_model(Vacancy), + object_id=vacancy.id, + ) + return self.update(request, *args, **kwargs)