Кастомные поля модели 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 для дополнительной настройки.