Добавлены тесты для Environment
This commit is contained in:
@@ -8,4 +8,8 @@
|
|||||||
Пакет описания тестов для служебных классов
|
Пакет описания тестов для служебных классов
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .date_utils_now_tests import DateUtilsNowTests
|
||||||
|
from .date_utils_shift_time_tests import DateUtilsShiftTimeTests
|
||||||
|
from .model_utils_concat_data_tests import ModelUtilsConcatDataTests
|
||||||
|
from .model_utils_update_data_tests import ModelUtilsUpdateDataTests
|
||||||
from .settings import *
|
from .settings import *
|
||||||
|
|||||||
103
net/xeaf/rack/tests/utils/_model_utils_mocks.py
Normal file
103
net/xeaf/rack/tests/utils/_model_utils_mocks.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# DRF Rack
|
||||||
|
# Библиотека классов расширений для Django REST Framework
|
||||||
|
#
|
||||||
|
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||||
|
# Все права защищены. Лицензия: MIT
|
||||||
|
|
||||||
|
"""
|
||||||
|
Описание mock-классов для тестов ModelUtils
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
class TestSampleModel(Model):
|
||||||
|
"""
|
||||||
|
Вспомогательный mock-класс модели для тестирования методов ModelUtils.
|
||||||
|
Предназначен для сценариев, где data является экземпляром модели.
|
||||||
|
Имитирует базовую модель Django с полями id, name и description.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = "test_app"
|
||||||
|
managed = False
|
||||||
|
|
||||||
|
id = models.IntegerField(primary_key=True, default=1)
|
||||||
|
name = models.CharField(max_length=100, default="test_name")
|
||||||
|
description = models.CharField(max_length=255, default="test_description")
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptyModel(Model):
|
||||||
|
"""
|
||||||
|
Вспомогательный mock-класс пустой модели для тестирования граничных случаев.
|
||||||
|
Предназначен для сценариев, где модель не содержит полей.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = "test_app"
|
||||||
|
managed = False
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelWithExtraFields(Model):
|
||||||
|
"""
|
||||||
|
Вспомогательный mock-класс модели с дополнительными полями.
|
||||||
|
Предназначен для сценариев, где необходимо проверить работу с полями,
|
||||||
|
которые могут отсутствовать в целевом словаре.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = "test_app"
|
||||||
|
managed = False
|
||||||
|
|
||||||
|
id = models.IntegerField(primary_key=True, default=1)
|
||||||
|
name = models.CharField(max_length=100, default="test_name")
|
||||||
|
email = models.EmailField(max_length=255, default="test@example.com")
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelWithSpecialFields(Model):
|
||||||
|
"""
|
||||||
|
Вспомогательный mock-класс модели с полями специальных типов.
|
||||||
|
Предназначен для проверки корректной обработки полей с None значениями
|
||||||
|
и полей, которые могут быть не установлены.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = "test_app"
|
||||||
|
managed = False
|
||||||
|
|
||||||
|
id = models.IntegerField(primary_key=True, default=1)
|
||||||
|
nullable_field = models.CharField(max_length=100, null=True, blank=True, default=None)
|
||||||
|
boolean_field = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelWithDifferentFieldTypes(Model):
|
||||||
|
"""
|
||||||
|
Вспомогательный mock-класс модели с полями различных типов.
|
||||||
|
Предназначен для проверки корректной обработки разнотипных полей.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = "test_app"
|
||||||
|
managed = False
|
||||||
|
|
||||||
|
id = models.IntegerField(primary_key=True, default=1)
|
||||||
|
integer_field = models.IntegerField(default=0)
|
||||||
|
float_field = models.FloatField(default=0.0)
|
||||||
|
text_field = models.TextField(default="")
|
||||||
|
boolean_field = models.BooleanField(default=False)
|
||||||
|
date_field = models.DateField(null=True, blank=True)
|
||||||
|
datetime_field = models.DateTimeField(null=True, blank=True)
|
||||||
101
net/xeaf/rack/tests/utils/date_utils_now_tests.py
Normal file
101
net/xeaf/rack/tests/utils/date_utils_now_tests.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# DRF Rack
|
||||||
|
# Библиотека классов расширений для Django REST Framework
|
||||||
|
#
|
||||||
|
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||||
|
# Все права защищены. Лицензия: MIT
|
||||||
|
|
||||||
|
"""
|
||||||
|
Описание класса DateUtilsNowTests
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from net.xeaf.rack.core.testing import CoreSimpleTestCase
|
||||||
|
from net.xeaf.rack.utils import DateUtils
|
||||||
|
|
||||||
|
|
||||||
|
class DateUtilsNowTests(CoreSimpleTestCase):
|
||||||
|
"""
|
||||||
|
Тестовый класс для DateUtils.now()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_now_returns_current_datetime(self):
|
||||||
|
"""
|
||||||
|
Проверяет, что метод now() возвращает текущую дату и время.
|
||||||
|
Тестирует позитивный сценарий получения текущего времени.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
expected_now = datetime.datetime(2024, 1, 15, 12, 30, 45, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with patch('django.utils.timezone.now', return_value=expected_now):
|
||||||
|
result = DateUtils.now()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_now)
|
||||||
|
self.assertIsInstance(result, datetime.datetime)
|
||||||
|
|
||||||
|
def test_now_returns_aware_datetime(self):
|
||||||
|
"""
|
||||||
|
Проверяет, что now() возвращает aware datetime (с часовым поясом).
|
||||||
|
Тестирует, что возвращаемое время содержит информацию о часовом поясе.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange & Act
|
||||||
|
result = DateUtils.now()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertIsNotNone(result.tzinfo)
|
||||||
|
self.assertTrue(timezone.is_aware(result))
|
||||||
|
|
||||||
|
def test_now_returns_correct_type(self):
|
||||||
|
"""
|
||||||
|
Проверяет, что now() возвращает объект типа datetime.datetime.
|
||||||
|
Тестирует корректность типа возвращаемого значения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange & Act
|
||||||
|
result = DateUtils.now()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertIsInstance(result, datetime.datetime)
|
||||||
|
|
||||||
|
def test_now_uses_django_timezone(self):
|
||||||
|
"""
|
||||||
|
Проверяет, что now() использует django.utils.timezone.now().
|
||||||
|
Тестирует, что метод вызывает правильную функцию из Django.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
expected_time = datetime.datetime(2024, 6, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with patch('django.utils.timezone.now', return_value=expected_time) as mock_now:
|
||||||
|
result = DateUtils.now()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_now.assert_called_once()
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_now_returns_different_values_on_consecutive_calls(self):
|
||||||
|
"""
|
||||||
|
Проверяет, что при последовательных вызовах now() возвращаются разные значения.
|
||||||
|
Тестирует, что метод действительно возвращает актуальное время.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
time1 = datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
time2 = datetime.datetime(2024, 1, 1, 0, 0, 1, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with patch('django.utils.timezone.now', side_effect=[time1, time2]):
|
||||||
|
result1 = DateUtils.now()
|
||||||
|
result2 = DateUtils.now()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertNotEqual(result1, result2)
|
||||||
|
self.assertLess(result1, result2)
|
||||||
306
net/xeaf/rack/tests/utils/date_utils_shift_time_tests.py
Normal file
306
net/xeaf/rack/tests/utils/date_utils_shift_time_tests.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
# DRF Rack
|
||||||
|
# Библиотека классов расширений для Django REST Framework
|
||||||
|
#
|
||||||
|
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||||
|
# Все права защищены. Лицензия: MIT
|
||||||
|
|
||||||
|
"""
|
||||||
|
Описание класса DateUtilsShiftTimeTests
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from net.xeaf.rack.core.testing import CoreSimpleTestCase
|
||||||
|
from net.xeaf.rack.utils import DateUtils
|
||||||
|
|
||||||
|
|
||||||
|
class DateUtilsShiftTimeTests(CoreSimpleTestCase):
|
||||||
|
"""
|
||||||
|
Тестовый класс для DateUtils.shift_time()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_shift_time_with_default_date_time(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени с использованием текущей даты по умолчанию.
|
||||||
|
Тестирует позитивный сценарий, когда date_time не передан.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 12, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 1, 20, 12, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with patch('django.utils.timezone.now', return_value=base_time):
|
||||||
|
result = DateUtils.shift_time(days=5)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_with_explicit_date_time(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени с явно переданной датой.
|
||||||
|
Тестирует позитивный сценарий с указанием конкретной даты.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 1, 20, 10, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, days=5)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_with_positive_values(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени с положительными значениями.
|
||||||
|
Тестирует добавление дней, часов, минут и секунд.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 10, 30, 15, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 1, 17, 15, 45, 30, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, days=2, hours=5, minutes=15, seconds=15)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_with_negative_values(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени с отрицательными значениями.
|
||||||
|
Тестирует вычитание дней, часов, минут и секунд.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 1, 13, 5, 15, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, days=-2, hours=-5, minutes=-15)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_with_zero_values(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени с нулевыми значениями.
|
||||||
|
Тестирует граничный случай, когда время не изменяется.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, days=0, hours=0, minutes=0, seconds=0)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, base_time)
|
||||||
|
|
||||||
|
def test_shift_time_with_only_hours(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени только по часам.
|
||||||
|
Тестирует добавление только часов без изменения других параметров.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 1, 15, 15, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, hours=5)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_with_only_minutes(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени только по минутам.
|
||||||
|
Тестирует добавление только минут без изменения других параметров.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 1, 15, 10, 45, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, minutes=15)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_with_only_seconds(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени только по секундам.
|
||||||
|
Тестирует добавление только секунд без изменения других параметров.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 1, 15, 10, 30, 30, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, seconds=30)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_preserves_timezone(self):
|
||||||
|
"""
|
||||||
|
Проверяет, что сдвиг времени сохраняет часовой пояс.
|
||||||
|
Тестирует корректность работы с aware datetime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, days=1, hours=2)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result.tzinfo, base_time.tzinfo)
|
||||||
|
self.assertTrue(timezone.is_aware(result))
|
||||||
|
|
||||||
|
def test_shift_time_crosses_day_boundary(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени с переходом через границу дня.
|
||||||
|
Тестирует корректность работы при добавлении часов, превышающих 24.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 23, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 1, 17, 2, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, hours=27)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_crosses_month_boundary(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени с переходом через границу месяца.
|
||||||
|
Тестирует корректность работы при добавлении дней, превышающих количество дней в месяце.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 30, 10, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 2, 3, 10, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, days=4)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_crosses_year_boundary(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени с переходом через границу года.
|
||||||
|
Тестирует корректность работы при добавлении дней, превышающих количество дней в году.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 12, 30, 10, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2025, 1, 3, 10, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, days=4)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_with_large_values(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени с большими значениями.
|
||||||
|
Тестирует работу с большими числами дней, часов и минут.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 2, 15, 12, 30, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, days=45, hours=12, minutes=30)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_with_none_date_time(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени при None в качестве date_time.
|
||||||
|
Тестирует, что при None используется текущее время.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 8, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 1, 20, 8, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with patch('django.utils.timezone.now', return_value=base_time):
|
||||||
|
result = DateUtils.shift_time(None, days=5)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_handles_daylight_saving(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени с учетом перехода на летнее время.
|
||||||
|
Тестирует корректность работы с DST (если применимо).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
tz = pytz.timezone('Europe/Moscow')
|
||||||
|
base_time = tz.localize(datetime.datetime(2024, 3, 30, 23, 0, 0))
|
||||||
|
expected_time = tz.localize(datetime.datetime(2024, 3, 31, 23, 0, 0))
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(base_time, days=1)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
|
|
||||||
|
def test_shift_time_returns_same_type_as_input(self):
|
||||||
|
"""
|
||||||
|
Проверяет, что возвращаемый тип соответствует входному.
|
||||||
|
Тестирует, что метод возвращает datetime даже при None входном значении.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 10, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result_with_date = DateUtils.shift_time(base_time, days=1)
|
||||||
|
with patch('django.utils.timezone.now', return_value=base_time):
|
||||||
|
result_without_date = DateUtils.shift_time(None, days=1)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertIsInstance(result_with_date, datetime.datetime)
|
||||||
|
self.assertIsInstance(result_without_date, datetime.datetime)
|
||||||
|
|
||||||
|
def test_shift_time_combination_of_all_parameters(self):
|
||||||
|
"""
|
||||||
|
Проверяет сдвиг времени с комбинацией всех параметров.
|
||||||
|
Тестирует одновременное добавление дней, часов, минут и секунд.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
base_time = datetime.datetime(2024, 1, 15, 10, 30, 15, tzinfo=datetime.timezone.utc)
|
||||||
|
expected_time = datetime.datetime(2024, 1, 25, 18, 45, 45, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = DateUtils.shift_time(
|
||||||
|
base_time,
|
||||||
|
days=10,
|
||||||
|
hours=8,
|
||||||
|
minutes=15,
|
||||||
|
seconds=30
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, expected_time)
|
||||||
181
net/xeaf/rack/tests/utils/model_utils_concat_data_tests.py
Normal file
181
net/xeaf/rack/tests/utils/model_utils_concat_data_tests.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# DRF Rack
|
||||||
|
# Библиотека классов расширений для Django REST Framework
|
||||||
|
#
|
||||||
|
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||||
|
# Все права защищены. Лицензия: MIT
|
||||||
|
|
||||||
|
"""
|
||||||
|
Описание класса ModelUtilsConcatDataTests
|
||||||
|
"""
|
||||||
|
|
||||||
|
from net.xeaf.rack.core.testing import CoreSimpleTestCase
|
||||||
|
from net.xeaf.rack.utils import ModelUtils
|
||||||
|
from ._model_utils_mocks import TestEmptyModel
|
||||||
|
from ._model_utils_mocks import TestModelWithExtraFields
|
||||||
|
from ._model_utils_mocks import TestModelWithSpecialFields
|
||||||
|
from ._model_utils_mocks import TestSampleModel
|
||||||
|
|
||||||
|
|
||||||
|
class ModelUtilsConcatDataTests(CoreSimpleTestCase):
|
||||||
|
"""
|
||||||
|
Тестовый класс для ModelUtils.concat_data()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_concat_data_with_dict_source_success(self):
|
||||||
|
"""
|
||||||
|
Проверяет успешное объединение данных из словаря-источника в целевой словарь.
|
||||||
|
Тестирует позитивный сценарий, когда источник - обычный словарь.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
source_data = {"name": "John", "age": 30, "city": "Moscow"}
|
||||||
|
target_data = {"name": "Existing", "country": "Russia"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.concat_data(source_data, target_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(target_data["name"], "Existing")
|
||||||
|
self.assertEqual(target_data["age"], 30)
|
||||||
|
self.assertEqual(target_data["city"], "Moscow")
|
||||||
|
self.assertEqual(target_data["country"], "Russia")
|
||||||
|
|
||||||
|
def test_concat_data_with_model_source_success(self):
|
||||||
|
"""
|
||||||
|
Проверяет успешное объединение данных из модели-источника в целевой словарь.
|
||||||
|
Тестирует позитивный сценарий, когда источник - экземпляр Model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
model_instance = TestSampleModel(id=5, name="Alice", description="Test description")
|
||||||
|
target_data = {"name": "Existing", "status": "active"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.concat_data(model_instance, target_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(target_data["name"], "Existing")
|
||||||
|
self.assertEqual(target_data["id"], 5)
|
||||||
|
self.assertEqual(target_data["description"], "Test description")
|
||||||
|
self.assertEqual(target_data["status"], "active")
|
||||||
|
|
||||||
|
def test_concat_data_with_empty_model_source(self):
|
||||||
|
"""
|
||||||
|
Проверяет обработку пустой модели в качестве источника данных.
|
||||||
|
Тестирует граничный случай, когда модель не имеет полей.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
model_instance = TestEmptyModel()
|
||||||
|
target_data = {"name": "Existing", "status": "active"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.concat_data(model_instance, target_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
# В пустой модели model_to_dict() вернет пустой словарь
|
||||||
|
self.assertEqual(target_data["name"], "Existing")
|
||||||
|
self.assertEqual(target_data["status"], "active")
|
||||||
|
self.assertEqual(len(target_data), 2)
|
||||||
|
|
||||||
|
def test_concat_data_with_empty_source_dict(self):
|
||||||
|
"""
|
||||||
|
Проверяет обработку пустого словаря в качестве источника данных.
|
||||||
|
Тестирует граничный случай, когда источник данных не содержит полей.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
source_data = {}
|
||||||
|
target_data = {"name": "Existing", "status": "active"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.concat_data(source_data, target_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(target_data["name"], "Existing")
|
||||||
|
self.assertEqual(target_data["status"], "active")
|
||||||
|
self.assertEqual(len(target_data), 2)
|
||||||
|
|
||||||
|
def test_concat_data_does_not_override_existing_keys(self):
|
||||||
|
"""
|
||||||
|
Проверяет, что существующие ключи в целевом словаре не перезаписываются.
|
||||||
|
Тестирует корректность логики пропуска уже существующих ключей.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
source_data = {"name": "John", "age": 30, "city": "Moscow"}
|
||||||
|
target_data = {"name": "Existing", "age": 25, "country": "Russia"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.concat_data(source_data, target_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(target_data["name"], "Existing")
|
||||||
|
self.assertEqual(target_data["age"], 25)
|
||||||
|
self.assertEqual(target_data["city"], "Moscow")
|
||||||
|
self.assertEqual(target_data["country"], "Russia")
|
||||||
|
|
||||||
|
def test_concat_data_with_model_containing_none_values(self):
|
||||||
|
"""
|
||||||
|
Проверяет обработку модели, содержащей поля со значением None.
|
||||||
|
Тестирует, что None значения корректно копируются в целевой словарь.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
model_instance = TestModelWithSpecialFields(id=3, nullable_field=None, boolean_field=True)
|
||||||
|
target_data = {"name": "Existing"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.concat_data(model_instance, target_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(target_data["name"], "Existing")
|
||||||
|
self.assertEqual(target_data["id"], 3)
|
||||||
|
self.assertIsNone(target_data["nullable_field"])
|
||||||
|
self.assertTrue(target_data["boolean_field"])
|
||||||
|
|
||||||
|
def test_concat_data_with_complex_model_fields(self):
|
||||||
|
"""
|
||||||
|
Проверяет обработку модели с различными типами полей.
|
||||||
|
Тестирует корректность копирования полей разных типов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
model_instance = TestModelWithExtraFields(
|
||||||
|
id=7,
|
||||||
|
name="Bob",
|
||||||
|
email="bob@example.com",
|
||||||
|
is_active=False
|
||||||
|
)
|
||||||
|
target_data = {"status": "pending"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.concat_data(model_instance, target_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(target_data["status"], "pending")
|
||||||
|
self.assertEqual(target_data["id"], 7)
|
||||||
|
self.assertEqual(target_data["name"], "Bob")
|
||||||
|
self.assertEqual(target_data["email"], "bob@example.com")
|
||||||
|
self.assertFalse(target_data["is_active"])
|
||||||
|
|
||||||
|
def test_concat_data_handles_non_string_keys(self):
|
||||||
|
"""
|
||||||
|
Проверяет обработку словаря с не строковыми ключами.
|
||||||
|
Тестирует, что метод корректно обрабатывает различные типы ключей.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
source_data = {1: "one", 2: "two", "key": "value"}
|
||||||
|
target_data = {"name": "Existing"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.concat_data(source_data, target_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(target_data["name"], "Existing")
|
||||||
|
# noinspection bad-index
|
||||||
|
self.assertEqual(target_data[1], "one")
|
||||||
|
# noinspection bad-index
|
||||||
|
self.assertEqual(target_data[2], "two")
|
||||||
|
self.assertEqual(target_data["key"], "value")
|
||||||
229
net/xeaf/rack/tests/utils/model_utils_update_data_tests.py
Normal file
229
net/xeaf/rack/tests/utils/model_utils_update_data_tests.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# DRF Rack
|
||||||
|
# Библиотека классов расширений для Django REST Framework
|
||||||
|
#
|
||||||
|
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||||
|
# Все права защищены. Лицензия: MIT
|
||||||
|
|
||||||
|
"""
|
||||||
|
Описание класса ModelUtilsUpdateDataTests
|
||||||
|
"""
|
||||||
|
|
||||||
|
from net.xeaf.rack.core.testing import CoreSimpleTestCase
|
||||||
|
from net.xeaf.rack.utils import ModelUtils
|
||||||
|
from ._model_utils_mocks import TestModelWithExtraFields
|
||||||
|
from ._model_utils_mocks import TestModelWithSpecialFields
|
||||||
|
from ._model_utils_mocks import TestSampleModel
|
||||||
|
|
||||||
|
|
||||||
|
class ModelUtilsUpdateDataTests(CoreSimpleTestCase):
|
||||||
|
"""
|
||||||
|
Тестовый класс для ModelUtils.update_data()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_update_data_with_model_instance_success(self):
|
||||||
|
"""
|
||||||
|
Проверяет успешное обновление атрибутов модели на основе переданных данных.
|
||||||
|
Тестирует позитивный сценарий обновления экземпляра модели.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
instance = TestSampleModel(id=1, name="Old Name", description="Old description")
|
||||||
|
validated_data = {"name": "New Name", "description": "New description"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.update_data(instance, validated_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(instance.name, "New Name")
|
||||||
|
self.assertEqual(instance.description, "New description")
|
||||||
|
self.assertEqual(instance.id, 1) # id не должен измениться
|
||||||
|
|
||||||
|
def test_update_data_with_dict_instance_success(self):
|
||||||
|
"""
|
||||||
|
Проверяет успешное обновление словаря-объекта на основе переданных данных.
|
||||||
|
Тестирует позитивный сценарий обновления словаря.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
instance = {"name": "Old Name", "age": 25, "city": "Moscow"}
|
||||||
|
validated_data = {"name": "New Name", "age": 30}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.update_data(instance, validated_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(instance["name"], "New Name")
|
||||||
|
self.assertEqual(instance["age"], 30)
|
||||||
|
self.assertEqual(instance["city"], "Moscow")
|
||||||
|
|
||||||
|
def test_update_data_skips_nonexistent_attributes(self):
|
||||||
|
"""
|
||||||
|
Проверяет, что метод пропускает атрибуты, отсутствующие в объекте.
|
||||||
|
Тестирует корректную обработку полей, которых нет в экземпляре.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
instance = TestSampleModel(id=1, name="Old Name")
|
||||||
|
validated_data = {"name": "New Name", "nonexistent_field": "Should be skipped"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.update_data(instance, validated_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(instance.name, "New Name")
|
||||||
|
self.assertFalse(hasattr(instance, "nonexistent_field"))
|
||||||
|
self.assertEqual(instance.id, 1)
|
||||||
|
|
||||||
|
def test_update_data_with_empty_validated_data(self):
|
||||||
|
"""
|
||||||
|
Проверяет обработку пустого словаря данных для обновления.
|
||||||
|
Тестирует граничный случай, когда нет данных для обновления.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
instance = TestSampleModel(id=1, name="Original Name", description="Original description")
|
||||||
|
validated_data = {}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.update_data(instance, validated_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(instance.name, "Original Name")
|
||||||
|
self.assertEqual(instance.description, "Original description")
|
||||||
|
self.assertEqual(instance.id, 1)
|
||||||
|
|
||||||
|
def test_update_data_with_dict_and_missing_keys(self):
|
||||||
|
"""
|
||||||
|
Проверяет обновление словаря с ключами, часть которых отсутствует.
|
||||||
|
Тестирует обработку ситуации, когда ключ из validated_data отсутствует в словаре.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
instance = {"name": "Old Name", "city": "Moscow"}
|
||||||
|
validated_data = {"name": "New Name", "age": 30}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.update_data(instance, validated_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(instance["name"], "New Name")
|
||||||
|
self.assertEqual(instance["city"], "Moscow")
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
# Новый ключ не будет добавлен
|
||||||
|
self.assertEqual(instance["age"], 30)
|
||||||
|
|
||||||
|
def test_update_data_partially_updates_model(self):
|
||||||
|
"""
|
||||||
|
Проверяет частичное обновление модели (не все поля переданы).
|
||||||
|
Тестирует, что обновляются только переданные поля.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
instance = TestSampleModel(id=1, name="Old Name", description="Old description")
|
||||||
|
validated_data = {"name": "New Name"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.update_data(instance, validated_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(instance.name, "New Name")
|
||||||
|
self.assertEqual(instance.description, "Old description")
|
||||||
|
self.assertEqual(instance.id, 1)
|
||||||
|
|
||||||
|
def test_update_data_with_model_containing_readonly_fields(self):
|
||||||
|
"""
|
||||||
|
Проверяет обновление модели с полями, которые могут быть защищены.
|
||||||
|
Тестирует, что метод не пытается обновить несуществующие атрибуты.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
instance = TestModelWithExtraFields(
|
||||||
|
id=5,
|
||||||
|
name="Old Name",
|
||||||
|
email="old@example.com",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
validated_data = {"name": "New Name", "email": "new@example.com"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.update_data(instance, validated_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(instance.name, "New Name")
|
||||||
|
self.assertEqual(instance.email, "new@example.com")
|
||||||
|
self.assertTrue(instance.is_active)
|
||||||
|
self.assertEqual(instance.id, 5)
|
||||||
|
|
||||||
|
def test_update_data_with_invalid_data_types(self):
|
||||||
|
"""
|
||||||
|
Проверяет обработку некорректных типов данных.
|
||||||
|
Тестирует, что метод корректно обрабатывает различные типы значений.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
instance = TestSampleModel(id=1, name="Old Name", description="Old description")
|
||||||
|
validated_data = {"name": 123, "description": None}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.update_data(instance, validated_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(instance.name, 123) # Любое значение будет присвоено
|
||||||
|
self.assertIsNone(instance.description)
|
||||||
|
self.assertEqual(instance.id, 1)
|
||||||
|
|
||||||
|
def test_update_data_with_nested_structures(self):
|
||||||
|
"""
|
||||||
|
Проверяет обновление с вложенными структурами данных.
|
||||||
|
Тестирует обработку сложных типов данных.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
instance = {"name": "Old", "data": {"key": "value"}}
|
||||||
|
validated_data = {"name": "New", "data": {"new_key": "new_value"}}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.update_data(instance, validated_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(instance["name"], "New")
|
||||||
|
self.assertEqual(instance["data"], {"new_key": "new_value"})
|
||||||
|
|
||||||
|
def test_update_data_with_bool_values(self):
|
||||||
|
"""
|
||||||
|
Проверяет обновление с булевыми значениями.
|
||||||
|
Тестирует корректную обработку True/False значений.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
instance = TestModelWithSpecialFields(id=1, nullable_field="test", boolean_field=False)
|
||||||
|
validated_data = {"boolean_field": True, "nullable_field": None}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.update_data(instance, validated_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertTrue(instance.boolean_field)
|
||||||
|
self.assertIsNone(instance.nullable_field)
|
||||||
|
self.assertEqual(instance.id, 1)
|
||||||
|
|
||||||
|
def test_update_data_does_not_modify_original_dict(self):
|
||||||
|
"""
|
||||||
|
Проверяет, что метод не создает копий и не изменяет исходные словари.
|
||||||
|
Тестирует, что обновление происходит по ссылке.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
instance = {"name": "Old", "age": 20}
|
||||||
|
validated_data = {"name": "New", "city": "Moscow"}
|
||||||
|
original_instance_id = id(instance)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
ModelUtils.update_data(instance, validated_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(id(instance), original_instance_id)
|
||||||
|
self.assertEqual(instance["name"], "New")
|
||||||
|
self.assertEqual(instance["age"], 20)
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
self.assertEqual(instance["city"], "Moscow")
|
||||||
@@ -20,7 +20,7 @@ class ModelUtils:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def concat_data(cls, data: dict[str, Any] | Model, target: dict[str, Any], ) -> None:
|
def concat_data(cls, data: dict[str | int, Any] | Model, target: dict[str | int, Any], ) -> None:
|
||||||
"""
|
"""
|
||||||
Объединяет данные
|
Объединяет данные
|
||||||
|
|
||||||
@@ -31,6 +31,8 @@ class ModelUtils:
|
|||||||
# Получаем объект данных
|
# Получаем объект данных
|
||||||
if isinstance(data, Model):
|
if isinstance(data, Model):
|
||||||
source = model_to_dict(data)
|
source = model_to_dict(data)
|
||||||
|
if 'id' in source and source['id'] is None:
|
||||||
|
del source['id']
|
||||||
else:
|
else:
|
||||||
source = data
|
source = data
|
||||||
|
|
||||||
@@ -48,6 +50,21 @@ class ModelUtils:
|
|||||||
:param validated_data: Данные для обновления
|
:param validated_data: Данные для обновления
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Для модели и словаря набор полей строится по-разному
|
||||||
|
if isinstance(instance, Model):
|
||||||
|
is_model = True
|
||||||
|
fields = [field.name for field in instance._meta.get_fields()]
|
||||||
|
elif isinstance(instance, dict):
|
||||||
|
is_model = False
|
||||||
|
fields = instance.keys()
|
||||||
|
else:
|
||||||
|
raise TypeError('Instance must be a Model or dict')
|
||||||
|
|
||||||
|
# Назначаем только существующие поля
|
||||||
for attr, value in validated_data.items():
|
for attr, value in validated_data.items():
|
||||||
if hasattr(instance, attr):
|
if attr in fields:
|
||||||
setattr(instance, attr, value)
|
if is_model:
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
else:
|
||||||
|
# noinspection unresolved-references
|
||||||
|
instance[attr] = value
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ dependencies = [
|
|||||||
"gunicorn",
|
"gunicorn",
|
||||||
"pillow",
|
"pillow",
|
||||||
"psycopg[binary]",
|
"psycopg[binary]",
|
||||||
|
"pytz",
|
||||||
"redis",
|
"redis",
|
||||||
"split_settings",
|
"split_settings",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user