Кастомные поля модели Django

Django позволяет создавать собственные типы полей для специальных нужд. Это мощный инструмент для расширения функциональности ORM и создания переиспользуемых компонентов.

Когда создавать кастомные поля

Кастомные поля стоит создавать когда:

  • Нужна специальная логика валидации
  • Требуется преобразование данных при сохранении/загрузке
  • Нужно хранить данные в нестандартном формате
  • Требуется специфическая логика отображения
  • Нужно переиспользовать логику в разных моделях

Базовое создание кастомного поля

1. Простое кастомное поле

 1from django.db import models
 2  from django.core.exceptions import ValidationError
 3
 4  class UpperCaseField(models.CharField):
 5      """Поле, которое автоматически преобразует текст в верхний регистр"""
 6
 7      def to_python(self, value):
 8          """Преобразование значения при загрузке из БД"""
 9          if value is None:
10              return value
11          if isinstance(value, str):
12              return value.upper()
13          return str(value).upper()
14
15      def get_prep_value(self, value):
16          """Подготовка значения для сохранения в БД"""
17          if value is None:
18              return value
19          return str(value).upper()
20
21      def validate(self, value, model_instance):
22          """Валидация значения"""
23          super().validate(value, model_instance)
24          if value and len(value) < 3:
25              raise ValidationError('Значение должно содержать минимум 3 символа')

2. Поле с дефолтным значением

 1class AutoSlugField(models.CharField):
 2      """Поле для автоматической генерации slug"""
 3
 4      def __init__(self, *args, **kwargs):
 5          kwargs['max_length'] = kwargs.get('max_length', 255)
 6          kwargs['unique'] = kwargs.get('unique', True)
 7          kwargs['blank'] = kwargs.get('blank', True)
 8          super().__init__(*args, **kwargs)
 9
10      def pre_save(self, model_instance, add):
11          """Выполняется перед сохранением модели"""
12          value = getattr(model_instance, self.attname)
13          if not value:
14              # Генерируем slug из названия
15              title = getattr(model_instance, 'title', '')
16              value = self.generate_slug(title)
17              setattr(model_instance, self.attname, value)
18          return value
19
20      def generate_slug(self, title):
21          """Генерация slug из заголовка"""
22          import re
23          import unicodedata
24
25          # Приводим к нижнему регистру
26          slug = title.lower()
27
28          # Убираем диакритические знаки
29          slug = unicodedata.normalize('NFKD', slug)
30          slug = ''.join([c for c in slug if not unicodedata.combining(c)])
31
32          # Заменяем пробелы и спецсимволы на дефисы
33          slug = re.sub(r'[^\w\s-]', '', slug)
34          slug = re.sub(r'[-\s]+', '-', slug)
35
36          return slug.strip('-')

Поля с валидацией

1. Поле для телефонного номера

 1import re
 2  from django.core.validators import RegexValidator
 3
 4  class PhoneNumberField(models.CharField):
 5      """Поле для валидации телефонного номера"""
 6
 7      def __init__(self, *args, **kwargs):
 8          kwargs['max_length'] = kwargs.get('max_length', 20)
 9          kwargs['validators'] = [
10              RegexValidator(
11                  regex=r'^\+?1?\d{9,15}$',
12                  message='Введите корректный номер телефона'
13              )
14          ]
15          super().__init__(*args, **kwargs)
16
17      def to_python(self, value):
18          """Очистка номера от лишних символов"""
19          if value is None:
20              return value
21
22          # Убираем все кроме цифр и +
23          cleaned = re.sub(r'[^\d+]', '', str(value))
24
25          # Добавляем + если его нет
26          if cleaned and not cleaned.startswith('+'):
27              cleaned = '+' + cleaned
28
29          return cleaned
30
31      def get_prep_value(self, value):
32          """Подготовка для сохранения"""
33          return self.to_python(value)

2. Поле для IP адреса

 1import ipaddress
 2  from django.core.exceptions import ValidationError
 3
 4  class IPAddressField(models.GenericIPAddressField):
 5      """Расширенное поле для IP адресов с дополнительной валидацией"""
 6
 7      def __init__(self, *args, **kwargs):
 8          self.allow_private = kwargs.pop('allow_private', True)
 9          self.allow_loopback = kwargs.pop('allow_loopback', False)
10          super().__init__(*args, **kwargs)
11
12      def validate(self, value, model_instance):
13          """Валидация IP адреса"""
14          super().validate(value, model_instance)
15
16          if value:
17              try:
18                  ip = ipaddress.ip_address(value)
19
20                  # Проверяем приватные адреса
21                  if not self.allow_private and ip.is_private:
22                      raise ValidationError('Приватные IP адреса не разрешены')
23
24                  # Проверяем loopback адреса
25                  if not self.allow_loopback and ip.is_loopback:
26                      raise ValidationError('Loopback адреса не разрешены')
27
28              except ValueError:
29                  raise ValidationError('Некорректный IP адрес')

Поля для работы с файлами

1. Поле для изображений с автоматическим ресайзом

 1from PIL import Image
 2  import os
 3  from django.core.files import File
 4  from django.core.files.temp import NamedTemporaryFile
 5
 6  class ResizedImageField(models.ImageField):
 7      """Поле для автоматического изменения размера изображений"""
 8
 9      def __init__(self, *args, **kwargs):
10          self.width = kwargs.pop('width', 800)
11          self.height = kwargs.pop('height', 600)
12          self.quality = kwargs.pop('quality', 85)
13          super().__init__(*args, **kwargs)
14
15      def save_form_data(self, instance, data):
16          """Обработка загруженного файла"""
17          if data and hasattr(data, 'temporary_file_path'):
18              # Обрабатываем временный файл
19              self.process_image(data.temporary_file_path, instance)
20          super().save_form_data(instance, data)
21
22      def process_image(self, file_path, instance):
23          """Обработка изображения"""
24          try:
25              with Image.open(file_path) as img:
26                  # Конвертируем в RGB если нужно
27                  if img.mode != 'RGB':
28                      img = img.convert('RGB')
29
30                  # Изменяем размер с сохранением пропорций
31                  img.thumbnail((self.width, self.height), Image.Resampling.LANCZOS)
32
33                  # Сохраняем обработанное изображение
34                  temp_file = NamedTemporaryFile(delete=False, suffix='.jpg')
35                  img.save(temp_file, 'JPEG', quality=self.quality)
36                  temp_file.close()
37
38                  # Привязываем к полю
39                  filename = os.path.basename(file_path)
40                  with open(temp_file.name, 'rb') as f:
41                      setattr(instance, self.attname, File(f, name=filename))
42
43                  # Удаляем временный файл
44                  os.unlink(temp_file.name)
45
46          except Exception as e:
47              # Логируем ошибку и используем оригинальный файл
48              import logging
49              logger = logging.getLogger(__name__)
50              logger.error(f"Ошибка обработки изображения: {e}")

2. Поле для загрузки файлов с проверкой типа

 1import magic
 2  from django.core.exceptions import ValidationError
 3
 4  class ValidatedFileField(models.FileField):
 5      """Поле для загрузки файлов с проверкой MIME типа"""
 6
 7      def __init__(self, *args, **kwargs):
 8          self.allowed_types = kwargs.pop('allowed_types', [])
 9          self.max_size = kwargs.pop('max_size', 10 * 1024 * 1024)  # 10MB
10          super().__init__(*args, **kwargs)
11
12      def validate(self, value, model_instance):
13          """Валидация файла"""
14          super().validate(value, model_instance)
15
16          if value:
17              # Проверяем размер
18              if value.size > self.max_size:
19                  raise ValidationError(
20                      f'Размер файла не должен превышать {self.max_size / (1024*1024):.1f}MB'
21                  )
22
23              # Проверяем MIME тип
24              if self.allowed_types:
25                  mime_type = magic.from_buffer(value.read(1024), mime=True)
26                  value.seek(0)  # Возвращаем указатель в начало
27
28                  if mime_type not in self.allowed_types:
29                      raise ValidationError(
30                          f'Неподдерживаемый тип файла. Разрешены: {", ".join(self.allowed_types)}'
31                      )

Поля для работы с JSON

1. Поле для хранения настроек

 1import json
 2  from django.core.exceptions import ValidationError
 3
 4  class SettingsField(models.JSONField):
 5      """Поле для хранения настроек с валидацией схемы"""
 6
 7      def __init__(self, *args, **kwargs):
 8          self.schema = kwargs.pop('schema', {})
 9          super().__init__(*args, **kwargs)
10
11      def validate(self, value, model_instance):
12          """Валидация по схеме"""
13          super().validate(value, model_instance)
14
15          if value and self.schema:
16              self.validate_schema(value, self.schema)
17
18      def validate_schema(self, data, schema):
19          """Рекурсивная валидация JSON схемы"""
20          if not isinstance(data, dict):
21              raise ValidationError('Значение должно быть объектом')
22
23          for key, rules in schema.items():
24              if key not in data:
25                  if rules.get('required', False):
26                      raise ValidationError(f'Обязательное поле {key} отсутствует')
27                  continue
28
29              value = data[key]
30              expected_type = rules.get('type')
31
32              if expected_type == 'string':
33                  if not isinstance(value, str):
34                      raise ValidationError(f'Поле {key} должно быть строкой')
35                  if 'min_length' in rules and len(value) < rules['min_length']:
36                      raise ValidationError(f'Поле {key} слишком короткое')
37                  if 'max_length' in rules and len(value) > rules['max_length']:
38                      raise ValidationError(f'Поле {key} слишком длинное')
39
40              elif expected_type == 'number':
41                  if not isinstance(value, (int, float)):
42                      raise ValidationError(f'Поле {key} должно быть числом')
43                  if 'min' in rules and value < rules['min']:
44                      raise ValidationError(f'Поле {key} меньше минимального значения')
45                  if 'max' in rules and value > rules['max']:
46                      raise ValidationError(f'Поле {key} больше максимального значения')
47
48              elif expected_type == 'boolean':
49                  if not isinstance(value, bool):
50                      raise ValidationError(f'Поле {key} должно быть булевым')
51
52              elif expected_type == 'object':
53                  if not isinstance(value, dict):
54                      raise ValidationError(f'Поле {key} должно быть объектом')
55                  if 'properties' in rules:
56                      self.validate_schema(value, rules['properties'])
57
58              elif expected_type == 'array':
59                  if not isinstance(value, list):
60                      raise ValidationError(f'Поле {key} должно быть массивом')
61                  if 'items' in rules:
62                      for item in value:
63                          self.validate_schema({'item': item}, {'item': rules['items']})

Поля для работы с датами и временем

1. Поле для временных зон

 1import pytz
 2  from django.core.exceptions import ValidationError
 3
 4  class TimeZoneField(models.CharField):
 5      """Поле для выбора временной зоны"""
 6
 7      def __init__(self, *args, **kwargs):
 8          kwargs['max_length'] = kwargs.get('max_length', 50)
 9          kwargs['choices'] = kwargs.get('choices', self.get_timezone_choices())
10          super().__init__(*args, **kwargs)
11
12      def get_timezone_choices(self):
13          """Получение списка временных зон"""
14          choices = []
15          for tz in pytz.all_timezones:
16              # Группируем по континентам
17              continent = tz.split('/')[0] if '/' in tz else 'Other'
18              choices.append((tz, f"{continent}: {tz}"))
19
20          return sorted(choices, key=lambda x: x[1])
21
22      def validate(self, value, model_instance):
23          """Валидация временной зоны"""
24          super().validate(value, model_instance)
25
26          if value and value not in pytz.all_timezones:
27              raise ValidationError('Некорректная временная зона')
28
29      def to_python(self, value):
30          """Преобразование значения"""
31          if value is None:
32              return value
33
34          # Приводим к строке и убираем лишние пробелы
35          value = str(value).strip()
36
37          # Проверяем существование временной зоны
38          if value and value not in pytz.all_timezones:
39              raise ValidationError('Некорректная временная зона')
40
41          return value

2. Поле для диапазона дат

 1from django.core.exceptions import ValidationError
 2
 3  class DateRangeField(models.JSONField):
 4      """Поле для хранения диапазона дат"""
 5
 6      def __init__(self, *args, **kwargs):
 7          kwargs['default'] = kwargs.get('default', dict)
 8          super().__init__(*args, **kwargs)
 9
10      def validate(self, value, model_instance):
11          """Валидация диапазона дат"""
12          super().validate(value, model_instance)
13
14          if value:
15              if not isinstance(value, dict):
16                  raise ValidationError('Значение должно быть объектом')
17
18              if 'start_date' not in value or 'end_date' not in value:
19                  raise ValidationError('Должны быть указаны start_date и end_date')
20
21              try:
22                  from datetime import datetime
23                  start = datetime.fromisoformat(value['start_date'])
24                  end = datetime.fromisoformat(value['end_date'])
25
26                  if start >= end:
27                      raise ValidationError('Дата начала должна быть раньше даты окончания')
28
29              except ValueError:
30                  raise ValidationError('Некорректный формат даты')
31
32      def to_python(self, value):
33          """Преобразование значения"""
34          if value is None:
35              return value
36
37          if isinstance(value, str):
38              try:
39                  import json
40                  value = json.loads(value)
41              except json.JSONDecodeError:
42                  raise ValidationError('Некорректный JSON')
43
44          return value

Создание кастомных виджетов

1. Виджет для цветового поля

 1from django import forms
 2
 3  class ColorPickerWidget(forms.Widget):
 4      """Виджет для выбора цвета"""
 5
 6      template_name = 'widgets/color_picker.html'
 7
 8      def get_context(self, name, value, attrs):
 9          context = super().get_context(name, value, attrs)
10          context['widget']['type'] = 'color'
11          context['widget']['attrs']['class'] = 'form-control color-picker'
12          return context
13
14  class ColorField(models.CharField):
15      """Поле для хранения цвета с валидацией hex кода"""
16
17      def __init__(self, *args, **kwargs):
18          kwargs['max_length'] = kwargs.get('max_length', 7)  # #RRGGBB
19          kwargs['default'] = kwargs.get('default', '#000000')
20          super().__init__(*args, **kwargs)
21
22      def formfield(self, **kwargs):
23          """Возвращает форму для поля"""
24          kwargs['widget'] = ColorPickerWidget
25          return super().formfield(**kwargs)
26
27      def validate(self, value, model_instance):
28          """Валидация hex кода цвета"""
29          super().validate(value, model_instance)
30
31          if value:
32              import re
33              if not re.match(r'^#[0-9A-Fa-f]{6}$', value):
34                  raise ValidationError('Некорректный hex код цвета')

2. Виджет для загрузки файлов с предпросмотром

 1class FilePreviewWidget(forms.FileInput):
 2      """Виджет для загрузки файлов с предпросмотром"""
 3
 4      template_name = 'widgets/file_preview.html'
 5
 6      def get_context(self, name, value, attrs):
 7          context = super().get_context(name, value, attrs)
 8
 9          if value and hasattr(value, 'url'):
10              context['widget']['file_url'] = value.url
11              context['widget']['file_name'] = value.name
12              context['widget']['file_size'] = value.size
13
14          return context
15
16  class PreviewFileField(models.FileField):
17      """Поле для файлов с предпросмотром"""
18
19      def formfield(self, **kwargs):
20          """Возвращает форму для поля"""
21          kwargs['widget'] = FilePreviewWidget
22          return super().formfield(**kwargs)

Практические примеры использования

1. Модель с кастомными полями

 1class Article(models.Model):
 2      title = models.CharField(max_length=200)
 3      slug = AutoSlugField(max_length=255)
 4      content = models.TextField()
 5
 6      # Кастомные поля
 7      phone = PhoneNumberField(blank=True, null=True)
 8      ip_address = IPAddressField(allow_private=False)
 9      settings = SettingsField(
10          schema={
11              'theme': {'type': 'string', 'required': True, 'choices': ['light', 'dark']},
12              'notifications': {'type': 'boolean', 'required': False},
13              'max_items': {'type': 'number', 'min': 1, 'max': 100}
14          },
15          default=dict
16      )
17      timezone = TimeZoneField(default='Europe/Moscow')
18      date_range = DateRangeField(blank=True, null=True)
19      color = ColorField(default='#000000')
20      image = ResizedImageField(
21          upload_to='articles/',
22          width=800,
23          height=600,
24          blank=True,
25          null=True
26      )
27
28      created_at = models.DateTimeField(auto_now_add=True)
29      updated_at = models.DateTimeField(auto_now=True)
30
31      class Meta:
32          db_table = 'articles'
33          verbose_name = 'Статья'
34          verbose_name_plural = 'Статьи'
35
36      def __str__(self):
37          return self.title
38
39      def clean(self):
40          """Валидация модели"""
41          super().clean()
42
43          # Дополнительная валидация
44          if self.date_range and self.date_range.get('start_date'):
45              from datetime import datetime
46              start_date = datetime.fromisoformat(self.date_range['start_date'])
47              if start_date < datetime.now():
48                  raise ValidationError('Дата начала не может быть в прошлом')

2. Форма для модели

 1class ArticleForm(forms.ModelForm):
 2      class Meta:
 3          model = Article
 4          fields = [
 5              'title', 'content', 'phone', 'ip_address',
 6              'settings', 'timezone', 'date_range', 'color', 'image'
 7          ]
 8
 9      def clean_settings(self):
10          """Валидация настроек"""
11          settings = self.cleaned_data.get('settings', {})
12
13          # Проверяем обязательные поля
14          if 'theme' not in settings:
15              raise ValidationError('Тема обязательна')
16
17          if 'max_items' in settings:
18              try:
19                  max_items = int(settings['max_items'])
20                  if max_items < 1 or max_items > 100:
21                      raise ValidationError('Количество элементов должно быть от 1 до 100')
22              except (ValueError, TypeError):
23                  raise ValidationError('Некорректное количество элементов')
24
25          return settings
26
27      def clean_date_range(self):
28          """Валидация диапазона дат"""
29          date_range = self.cleaned_data.get('date_range')
30
31          if date_range:
32              from datetime import datetime, timedelta
33
34              start_date = datetime.fromisoformat(date_range['start_date'])
35              end_date = datetime.fromisoformat(date_range['end_date'])
36
37              # Проверяем, что диапазон не больше 30 дней
38              if (end_date - start_date).days > 30:
39                  raise ValidationError('Диапазон дат не может превышать 30 дней')
40
41          return date_range

Тестирование кастомных полей

 1# tests.py
 2  from django.test import TestCase
 3  from django.core.exceptions import ValidationError
 4  from .models import Article
 5
 6  class CustomFieldsTestCase(TestCase):
 7      def test_phone_number_field(self):
 8          """Тестирование поля телефонного номера"""
 9          article = Article(
10              title='Test Article',
11              phone='+7 (999) 123-45-67'
12          )
13
14          # Проверяем очистку номера
15          article.full_clean()
16          self.assertEqual(article.phone, '+79991234567')
17
18      def test_settings_field_validation(self):
19          """Тестирование валидации поля настроек"""
20          article = Article(
21              title='Test Article',
22              settings={
23                  'theme': 'light',
24                  'max_items': 50
25              }
26          )
27
28          article.full_clean()
29          self.assertEqual(article.settings['theme'], 'light')
30
31      def test_settings_field_invalid_schema(self):
32          """Тестирование невалидной схемы настроек"""
33          article = Article(
34              title='Test Article',
35              settings={
36                  'theme': 'invalid_theme',
37                  'max_items': 150  # Больше максимума
38              }
39          )
40
41          with self.assertRaises(ValidationError):
42              article.full_clean()
43
44      def test_date_range_field(self):
45          """Тестирование поля диапазона дат"""
46          from datetime import datetime, timedelta
47
48          start_date = datetime.now()
49          end_date = start_date + timedelta(days=7)
50
51          article = Article(
52              title='Test Article',
53              date_range={
54                  'start_date': start_date.isoformat(),
55                  'end_date': end_date.isoformat()
56              }
57          )
58
59          article.full_clean()
60          self.assertIsNotNone(article.date_range)

Миграции для кастомных полей

 1# migrations/0001_initial.py
 2  from django.db import migrations, models
 3
 4  class Migration(migrations.Migration):
 5      initial = True
 6
 7      dependencies = []
 8
 9      operations = [
10          migrations.CreateModel(
11              name='Article',
12              fields=[
13                  ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
14                  ('title', models.CharField(max_length=200)),
15                  ('slug', models.CharField(max_length=255, unique=True, blank=True)),
16                  ('content', models.TextField()),
17                  ('phone', models.CharField(blank=True, max_length=20, null=True)),
18                  ('ip_address', models.GenericIPAddressField()),
19                  ('settings', models.JSONField(default=dict)),
20                  ('timezone', models.CharField(choices=[...], default='Europe/Moscow', max_length=50)),
21                  ('date_range', models.JSONField(blank=True, null=True)),
22                  ('color', models.CharField(default='#000000', max_length=7)),
23                  ('image', models.ImageField(blank=True, null=True, upload_to='articles/')),
24                  ('created_at', models.DateTimeField(auto_now_add=True)),
25                  ('updated_at', models.DateTimeField(auto_now=True)),
26              ],
27              options={
28                  'verbose_name': 'Статья',
29                  'verbose_name_plural': 'Статьи',
30                  'db_table': 'articles',
31              },
32          ),
33      ]

FAQ

Q: Когда создавать кастомные поля?
A: Когда нужна специальная логика валидации, преобразования или хранения данных, которая не покрывается стандартными полями Django.

Q: Как правильно наследовать от Field?
A: Наследуйся от соответствующего базового поля (CharField, IntegerField и т.д.), переопределяй необходимые методы и используй super() для вызова родительских методов.

Q: Какие методы нужно переопределять?
A: to_python() для преобразования при загрузке, get_prep_value() для подготовки к сохранению, validate() для валидации, formfield() для кастомных виджетов.

Q: Как валидировать сложные данные?
A: Используй метод validate() для проверки логики, создавай кастомные валидаторы и используй JSONField с схемой для структурированных данных.

Q: Можно ли создавать кастомные виджеты?
A: Да, создавай классы, наследующиеся от forms.Widget, переопределяй template_name и get_context(), затем используй formfield() для подключения.

Q: Как тестировать кастомные поля?
A: Создавай тесты для валидации, преобразования данных, проверяй работу с формами и создавай тестовые модели для интеграционного тестирования.

Q: Как обрабатывать ошибки в кастомных полях?
A: Используй ValidationError для ошибок валидации, логируй ошибки в сложных операциях и предоставляй понятные сообщения об ошибках пользователям.

Q: Можно ли использовать кастомные поля в админке?
A: Да, Django автоматически использует formfield() для создания форм в админке. Можно также переопределить get_form() в ModelAdmin для дополнительной настройки.