diff --git a/crm_yandex/ambassadors/__init__.py b/crm_yandex/ambassadors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm_yandex/ambassadors/admin.py b/crm_yandex/ambassadors/admin.py new file mode 100644 index 0000000..9c5c757 --- /dev/null +++ b/crm_yandex/ambassadors/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin + +from ambassadors.models import (Activity, Ambassador, AmbassadorActivity, + AmbassadorPreference, Content, MerchOnShipping, + MerchShipment, Preference, Venue) + + +admin.site.register(Activity) +admin.site.register(Ambassador) +admin.site.register(AmbassadorActivity) +admin.site.register(AmbassadorPreference) +admin.site.register(Content) +admin.site.register(MerchOnShipping) +admin.site.register(MerchShipment) +admin.site.register(Preference) +admin.site.register(Venue) diff --git a/crm_yandex/ambassadors/apps.py b/crm_yandex/ambassadors/apps.py new file mode 100644 index 0000000..5d6f2dc --- /dev/null +++ b/crm_yandex/ambassadors/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AmbassadorsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "ambassadors" diff --git a/crm_yandex/ambassadors/constants.py b/crm_yandex/ambassadors/constants.py new file mode 100644 index 0000000..15b0f97 --- /dev/null +++ b/crm_yandex/ambassadors/constants.py @@ -0,0 +1,65 @@ +STATUS_MAX_LEN: int = 50 +NAME_MAX_LEN: int = 100 +SIZE_MAX_LEN: int = 10 +GOAL_MAX_LEN: int = 255 +DECIMAL_MAX_DIGITS: int = 10 +DECIMAL_PLACES: int = 2 +AMBASSADOR_STATUS_CHOICES = [ + ('active', 'Активный'), + ('paused', 'На паузе'), + ('left', 'Ушёл'), + ('pending', 'Уточняется'), +] +GUIDE_STATUS_CHOICES = [ + ('not_completed', 'Не пройден'), + ('partially_completed', 'Пройдена 1 часть гайда'), + ('completed', 'Полностью пройден'), +] +SEX_CHOICES = [ + ('М', 'Мужчина'), + ('Ж', 'Женщина') +] +SEX_MAX_LEN: int = 1 +COURSE_CHOICES = [ + ('analyst', 'Аналитик данных'), + ('data_scientist', 'Специалист по Data Science'), + ('python_dev', 'Python-разработчик'), + ('web_dev', 'Веб-разработчик'), + ('qa_engineer', 'Инженер по тестированию(QA)'), + ('ux_ui_designer', 'UX/UI-дизайнер'), + ('marketing', 'Маркетинг'), + ('graphic_designer', 'Графический дизайнер'), + ('middle_python', 'Middle Python'), + ('c_plus_plus', 'C++'), + ('data_engineer', 'Инженер данных'), + ('it_recruiter', 'IT-рекрутер'), + ('management', 'Управление'), + ('english', 'Английский'), + ('critical_thinking', 'Критическое мышление'), + ('business_communication', 'Рабочая коммуникация'), + ('developer_algorithms', 'Алгоритмы для разработчиков'), + ('product_design', 'Продуктовый дизайн'), + ('sql_data_analytics', 'SQL для работы с данными и аналитики'), + ('java_dev', 'Java-разработчик'), + ('commercial_illustrator', 'Коммерческий иллюстратор'), + ('fullstack_dev', 'Фулстек разработчик'), + ('advanced_go_dev', 'Продвинутый GO-разработчик'), + ('devops', 'DevOps для эксплуатации и разработки'), + ('ios_dev', 'IOS-разработчик'), + ('business_analyst', 'Бизнес-аналитик'), + ('product_manager_exp', 'Продакт-менеджер для специалистов с опытом'), + ('android_dev', 'Андроид-разработчик'), + ('project_manager', 'Менеджер проектов'), + ] +PHONE_NUM_MAX_LEN: int = 20 +CLOTHING_SIZE_CHOICES = [ + ('xs', 'XS'), + ('s', 'S'), + ('m', 'M'), + ('l', 'L'), + ('xl', 'XL'), +] +CLOTHING_SIZE_MAX_LEN: int = 2 +PROMOCODE_MAX_LEN: int = 20 +PREFERENCE_MAX_LEN: int = 255 +TELEGRAM_MAX_LEN: int = 32 diff --git a/crm_yandex/ambassadors/models.py b/crm_yandex/ambassadors/models.py new file mode 100644 index 0000000..a5d2cc9 --- /dev/null +++ b/crm_yandex/ambassadors/models.py @@ -0,0 +1,305 @@ +from django.db import models + +from ambassadors.constants import (AMBASSADOR_STATUS_CHOICES, + CLOTHING_SIZE_CHOICES, + CLOTHING_SIZE_MAX_LEN, COURSE_CHOICES, + DECIMAL_MAX_DIGITS, DECIMAL_PLACES, + GOAL_MAX_LEN, NAME_MAX_LEN, + PHONE_NUM_MAX_LEN, PREFERENCE_MAX_LEN, + PROMOCODE_MAX_LEN, SEX_CHOICES, SEX_MAX_LEN, + STATUS_MAX_LEN, TELEGRAM_MAX_LEN) +from ambassadors.validators import (POSTAL_CODE_VALIDATOR, + TELEGRAM_USERNAME_VALIDATOR) + + +class Activity(models.Model): + name = models.CharField( + max_length=NAME_MAX_LEN, + verbose_name="Действие" + ) + + class Meta: + verbose_name = "Действие в рамках амбассадорства" + verbose_name_plural = "Действия в рамках амбассадорства" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Preference(models.Model): + name = models.CharField( + max_length=PREFERENCE_MAX_LEN, + verbose_name="Предпочтение" + ) + + class Meta: + verbose_name = "Предпочтение в рамкха амбассадорства" + verbose_name_plural = "Предпочтения в рамкха амбассадорства" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Ambassador(models.Model): + fio = models.CharField( + max_length=NAME_MAX_LEN, + verbose_name="Фамилия Имя Отчество" + ) + sex = models.CharField( + max_length=SEX_MAX_LEN, + choices=SEX_CHOICES, + verbose_name="Пол амбассадора" + ) + course = models.CharField( + max_length=NAME_MAX_LEN, + choices=COURSE_CHOICES, + verbose_name="Программа обучения" + ) + country = models.CharField( + max_length=NAME_MAX_LEN, + verbose_name="Страна", + ) + city = models.CharField( + max_length=NAME_MAX_LEN, + verbose_name="Город", + ) + address = models.CharField( + max_length=NAME_MAX_LEN, + verbose_name="Адрес проживания" + ) + postal_code = models.PositiveIntegerField( + verbose_name="Почтовый индекс", + validators=(POSTAL_CODE_VALIDATOR,) + ) + email = models.EmailField(verbose_name="Электронная почта") + phone_number = models.CharField( + max_length=PHONE_NUM_MAX_LEN, + verbose_name="Номер телефона" + ) + telegram = models.CharField( + max_length=TELEGRAM_MAX_LEN, + verbose_name="Телеграм", + validators=(TELEGRAM_USERNAME_VALIDATOR,) + ) + education = models.CharField( + max_length=NAME_MAX_LEN, + verbose_name="Образование" + ) + job = models.CharField( + max_length=NAME_MAX_LEN, + verbose_name="Текущая работа" + ) + goal = models.CharField( + max_length=GOAL_MAX_LEN, + verbose_name="Цель в Практикуме" + ) + activities = models.ManyToManyField( + Activity, + through="AmbassadorActivity", + verbose_name="Амбассадорские действия", + related_name="ambassadors", + through_fields=("ambassador", "activity") + ) + blog_link = models.URLField(verbose_name="Ссылка на блог") + clothing_size = models.CharField( + max_length=CLOTHING_SIZE_MAX_LEN, + choices=CLOTHING_SIZE_CHOICES, + verbose_name="Размер одежды" + ) + foot_size = models.PositiveSmallIntegerField( + verbose_name="Размер ноги" + ) + comment = models.TextField( + verbose_name="Комментарий" + ) + registration_date = models.DateField(verbose_name="Дата регистрации") + promocode = models.CharField( + max_length=PROMOCODE_MAX_LEN, + verbose_name="Промокод" + ) + status = models.CharField( + max_length=STATUS_MAX_LEN, + choices=AMBASSADOR_STATUS_CHOICES, + verbose_name="Статус амбассадора" + ) + preferences = models.ManyToManyField( + Preference, + through="AmbassadorPreference", + verbose_name="Предпочтения амбассадора", + related_name="ambassadors", + through_fields=("ambassador", "preference") + ) + guide_one = models.BooleanField(verbose_name="Гайд 1") + guide_two = models.BooleanField(verbose_name="Гайд 2") + onboarding = models.BooleanField(verbose_name="Онбординг") + + class Meta: + verbose_name = "Амбассадор" + verbose_name_plural = "Амбассадоры" + ordering = ["-registration_date"] + + def __str__(self): + return self.fio + + +class AmbassadorPreference(models.Model): + ambassador = models.ForeignKey( + Ambassador, + on_delete=models.CASCADE, + related_name="ambassador_preferences", + verbose_name="ID амбассадора" + ) + preference = models.ForeignKey( + Preference, + on_delete=models.CASCADE, + related_name="ambassador_preferences", + verbose_name="ID предпочтения" + ) + + class Meta: + verbose_name = "Амбассадор-Предпочтение" + verbose_name_plural = "Амбассадор-Предпочтения" + + def __str__(self): + return f"{self.ambassador} - {self.preference}" + + +class AmbassadorActivity(models.Model): + ambassador = models.ForeignKey( + Ambassador, + on_delete=models.CASCADE, + related_name="ambassador_activities", + verbose_name="ID амбассадора" + ) + activity = models.ForeignKey( + Activity, + on_delete=models.CASCADE, + related_name="ambassador_activities", + verbose_name="ID действия" + ) + + class Meta: + verbose_name = "Амбассадор-Действие" + verbose_name_plural = "Амбассадор-Действия" + + def __str__(self): + return f"{self.ambassador} - {self.activity}" + + +class Merch(models.Model): + merch_type = models.CharField( + max_length=NAME_MAX_LEN, + verbose_name="Мерч" + ) + cost = models.DecimalField( + max_digits=DECIMAL_MAX_DIGITS, + decimal_places=DECIMAL_PLACES, + verbose_name="Стоимость" + ) + + class Meta: + verbose_name = "Мерч" + verbose_name_plural = "Мерчи" + ordering = ["merch_type"] + + def __str__(self): + return f"{self.merch_type} - {self.cost}" + + +class MerchShipment(models.Model): + curator = models.CharField( + max_length=NAME_MAX_LEN, + verbose_name="Куратор" + ) + ambassador = models.ForeignKey( + Ambassador, + on_delete=models.CASCADE, + related_name="merch_shipments", + verbose_name="ID амбассадора" + ) + date = models.DateField() + merches = models.ManyToManyField( + Merch, + through="MerchOnShipping", + verbose_name="Амбассадорские действия", + related_name="merch_shipments", + through_fields=("shipping", "merch") + ) + comment = models.TextField( + verbose_name="Комментарий" + ) + + class Meta: + verbose_name = "Отправка мерча" + verbose_name_plural = "Отправки мерча" + ordering = ["-date"] + + def __str__(self): + return f"Ambassador ID: {self.ambassador.id} - {self.date}" + + +class MerchOnShipping(models.Model): + shipping = models.ForeignKey( + MerchShipment, + on_delete=models.CASCADE, + related_name="merches_on_shipping", + verbose_name="ID отправки" + ) + merch = models.ForeignKey( + Merch, + on_delete=models.CASCADE, + related_name="merches_on_shipping", + verbose_name="Мерч" + ) + + class Meta: + verbose_name = "Мерч в отправке" + verbose_name_plural = "Мерч в отправке" + + def __str__(self): + return f"{self.shipping} - {self.merch}" + + +class Venue(models.Model): + name = models.CharField( + max_length=NAME_MAX_LEN, + verbose_name="Название площадки" + ) + + class Meta: + verbose_name = "Площадка" + verbose_name_plural = "Площадки" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Content(models.Model): + ambassador = models.ForeignKey( + Ambassador, + on_delete=models.CASCADE, + related_name="content", + verbose_name="Амбассадор" + ) + link = models.URLField() + venue = models.ForeignKey( + Venue, + on_delete=models.CASCADE, + related_name="content", + verbose_name="Площадка" + ) + date = models.DateField() + guide_followed = models.BooleanField(verbose_name="По гайду да/нет") + + class Meta: + verbose_name = "Контент амбассадора" + verbose_name_plural = "Контент амбассадора" + ordering = ["-date"] + + def __str__(self): + return ( + f"Амбассадор: {self.ambassador} {self.link}" + ) diff --git a/crm_yandex/ambassadors/tests.py b/crm_yandex/ambassadors/tests.py new file mode 100644 index 0000000..a573fc2 --- /dev/null +++ b/crm_yandex/ambassadors/tests.py @@ -0,0 +1,163 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from ambassadors.models import (Activity, Ambassador, AmbassadorActivity, + AmbassadorPreference, Content, Merch, + MerchOnShipping, MerchShipment, Preference, + Venue) +from ambassadors.validators import (POSTAL_CODE_VALIDATOR, + TELEGRAM_USERNAME_VALIDATOR) + + +class AmbassadorTests(TestCase): + + def setUp(self): + self.activity = Activity.objects.create(name="Test_activity") + self.preference = Preference.objects.create(name="Test_preference") + self.ambassador = Ambassador.objects.create( + fio="Тест Тестов Тестович", + sex="М", + course="analyst", + country="test_country", + city="test_city", + address="test_address", + postal_code="123456", + email="testuser@yandex.ru", + telegram="@test_telegram", + phone_number="+75555555555", + education="Test", + job="test_job", + goal="test_goal", + blog_link="https://www.example.com", + clothing_size="xs", + foot_size="40", + comment="test_comment", + registration_date="2024-01-01", + promocode="test_promo", + status="active", + guide_one=False, + guide_two=False, + onboarding=False, + ) + self.merch = Merch.objects.create( + merch_type="test", + cost="25.00", + ) + self.merch_shipment = MerchShipment.objects.create( + curator="Test Test", + ambassador=self.ambassador, + date="2024-01-01", + comment="test_comment" + ) + self.venue = Venue.objects.create(name="test_venue") + self.content = Content.objects.create( + ambassador=self.ambassador, + link="https://www.example.com", + venue=self.venue, + date="2024-01-01", + guide_followed=False + ) + self.ambassador_activity = AmbassadorActivity.objects.create( + ambassador=self.ambassador, + activity=self.activity + ) + self.ambassador_preference = AmbassadorPreference.objects.create( + ambassador=self.ambassador, + preference=self.preference + ) + self.merch_on_shipping = MerchOnShipping.objects.create( + shipping=self.merch_shipment, + merch=self.merch + ) + + def test_activity_listing(self): + self.assertEqual(self.activity.name, "Test_activity") + + def test_preference_listing(self): + self.assertEqual(self.preference.name, "Test_preference") + + def test_ambassador_listing(self): + self.assertEqual(self.ambassador.fio, "Тест Тестов Тестович") + self.assertEqual(self.ambassador.sex, "М") + self.assertEqual(self.ambassador.course, "analyst") + self.assertEqual(self.ambassador.country, "test_country") + self.assertEqual(self.ambassador.city, "test_city") + self.assertEqual(self.ambassador.address, "test_address") + self.assertEqual(self.ambassador.postal_code, "123456") + self.assertEqual(self.ambassador.email, "testuser@yandex.ru") + self.assertEqual(self.ambassador.phone_number, "+75555555555") + self.assertEqual(self.ambassador.telegram, "@test_telegram") + self.assertEqual(self.ambassador.education, "Test") + self.assertEqual(self.ambassador.job, "test_job") + self.assertEqual(self.ambassador.goal, "test_goal") + self.assertEqual(self.ambassador.blog_link, "https://www.example.com") + self.assertEqual(self.ambassador.clothing_size, "xs") + self.assertEqual(self.ambassador.foot_size, "40") + self.assertEqual(self.ambassador.comment, "test_comment") + self.assertEqual(self.ambassador.registration_date, "2024-01-01") + self.assertEqual(self.ambassador.promocode, "test_promo") + self.assertEqual(self.ambassador.status, "active") + self.assertEqual(self.ambassador.guide_one, False) + self.assertEqual(self.ambassador.guide_two, False) + self.assertEqual(self.ambassador.onboarding, False) + + def test_merch_listing(self): + self.assertEqual(self.merch.merch_type, "test") + self.assertEqual(self.merch.cost, "25.00") + + def test_merch_shipment_listing(self): + self.assertEqual(self.merch_shipment.curator, "Test Test") + self.assertEqual( + self.merch_shipment.ambassador.fio, + "Тест Тестов Тестович" + ) + self.assertEqual(self.merch_shipment.date, "2024-01-01") + self.assertEqual(self.merch_shipment.comment, "test_comment") + + def test_models_have_correct_object_names(self): + model_str = { + self.activity: self.activity.name, + self.preference: self.preference.name, + self.ambassador: self.ambassador.fio, + self.merch: f"{self.merch.merch_type} - {self.merch.cost}", + self.merch_shipment: ( + f"Ambassador ID: {self.merch_shipment.ambassador.id} - " + f"{self.merch_shipment.date}" + ), + self.venue: self.venue.name, + self.content: ( + f"Амбассадор: {self.content.ambassador.fio} " + f"{self.content.link}" + ) + } + for model, expected_value in model_str.items(): + with self.subTest(model=model): + self.assertEqual(expected_value, str(model)) + + def test_relationships(self): + self.assertEqual(self.ambassador_activity.ambassador, self.ambassador) + self.assertEqual(self.ambassador_activity.activity, self.activity) + self.assertEqual( + self.ambassador_preference.ambassador, self.ambassador + ) + self.assertEqual( + self.ambassador_preference.preference, self.preference + ) + self.assertEqual(self.merch_on_shipping.shipping, self.merch_shipment) + self.assertEqual(self.merch_on_shipping.merch, self.merch) + self.assertEqual(self.merch_shipment.ambassador, self.ambassador) + self.assertEqual(self.content.venue, self.venue) + self.assertEqual(self.content.ambassador, self.ambassador) + + +class ModelValidatorTests(TestCase): + + def test_postal_code_validator(self): + invalid_postal_code = "1234567" + with self.assertRaises(ValidationError): + POSTAL_CODE_VALIDATOR(invalid_postal_code) + + def test_telegram_username_validator(self): + invalid_telegram_username = "test_username" + with self.assertRaises(ValidationError): + TELEGRAM_USERNAME_VALIDATOR(invalid_telegram_username) diff --git a/crm_yandex/ambassadors/validators.py b/crm_yandex/ambassadors/validators.py new file mode 100644 index 0000000..142567f --- /dev/null +++ b/crm_yandex/ambassadors/validators.py @@ -0,0 +1,12 @@ +from django.core.validators import RegexValidator + +POSTAL_CODE_VALIDATOR = RegexValidator( + regex=r"^\d{6}$", + message="Почтовый индекс должен состоять из 6 цифр" + ) + +TELEGRAM_USERNAME_VALIDATOR = RegexValidator( + regex=r"^@[a-zA-Z0-9_]{5,32}$", + message=("Telegram ID должен начинаться с @ и содержать от 5 до 32 " + "символов: буквы, цифры и символ _") +) diff --git a/crm_yandex/crm_yandex/settings.py b/crm_yandex/crm_yandex/settings.py index 52d14a1..ade5f7e 100644 --- a/crm_yandex/crm_yandex/settings.py +++ b/crm_yandex/crm_yandex/settings.py @@ -44,6 +44,7 @@ "rest_framework", "djoser", "users.apps.UsersConfig", + "ambassadors.apps.AmbassadorsConfig", ] MIDDLEWARE = [