IT-журнал

Django‑daily‑notes: фичи, лайфхаки и улучшения, которые спасут ваш день

Практический разбор создания приложения «ежедневные заметки» в Django 4.2 с советами по моделям, API, кешу, async и типичными граблями.

Обновлено 21.09.2026

⏱ 3 мин чтения · 👁 прочитали 3

Вступление

Каждый разработчик хранит в голове список идей, баг‑фиксов и маленьких задач. Превратить эти «мелочи» в сервис, доступный из браузера и мобильных клиентов — отличная возможность отточить навыки Django и собрать набор полезных лайфхаков. В статье мы построим приложение DailyNotes: CRUD‑API, поиск по дате, автоматическое удаление устаревших записей и несколько оптимизаций, которые часто забывают даже опытные бэкендеры.

Что вы получите:

  • готовый код моделей, сериализаторов, view‑setов и роутов;
  • примеры management‑команды и сигнала;
  • рекомендации по индексации, кешу и async‑вью;
  • список типичных подводных камней и проверенных решений.

1. Контекст и предпосылки

Параметр Значение
Python 3.11
Django 4.2 (LTS)
Django REST Framework 3.15
База данных PostgreSQL 15
СУБД‑фичи JSONField, GIN‑индекс, generated columns

Проект небольшого микросервиса: одна таблица Note, публичный read‑only эндпоинт и защищённый CRUD для авторизованных пользователей. Ограничения:

  • Записи живут максимум 30 дней, потом удаляются автоматически.
  • Нужно поддерживать быстрый поиск по дате без полного сканирования таблицы.
  • Приложение будет обслуживать до 200 RPS, поэтому важно избежать N+1 запросов.

2. Пошаговая реализация

2.1 Модель

# notes/models.py
from django.conf import settings
from django.db import models
from django.utils import timezone

class Note(models.Model):
    """Ежедневная заметка пользователя."""
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='notes',
    )
    title = models.CharField(max_length=200)
    body = models.TextField()
    created_at = models.DateTimeField(default=timezone.now, db_index=True)
    tags = models.JSONField(default=list, blank=True)  # требует PostgreSQL
    # Генерируемый столбец для ускоренного поиска по дате (available from Django 4.2)
    day = models.DateField(
        editable=False,
        db_index=True,
    )

    class Meta:
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['owner', 'day'], name='owner_day_idx'),
        ]

    def save(self, *args, **kwargs):
        # Заполняем day автоматически, чтобы можно было группировать по дню
        if not self.day:
            self.day = self.created_at.date()
        super().save(*args, **kwargs)

    def __str__(self):
        return f'{self.title} ({self.owner})'
  • JSONField хранит список тегов без отдельной ManyToMany‑таблицы.
  • day — дата создания, индексирована совместно с owner для быстрых запросов вида “записки за конкретный день”.
  • save() гарантирует согласованность day даже при миграции данных.

2.2 Сериализатор

# notes/serializers.py
from rest_framework import serializers
from .models import Note

class NoteSerializer(serializers.ModelSerializer):
    owner = serializers.ReadOnlyField(source='owner.username')
    age_days = serializers.SerializerMethodField()

    class Meta:
        model = Note
        fields = ('id', 'owner', 'title', 'body', 'tags',
                  'created_at', 'age_days')
        read_only_fields = ('created_at',)

    def get_age_days(self, obj):
        return (timezone.now().date() - obj.created_at.date()).days
  • owner выводится только как username, запись создаётся через perform_create.
  • age_days демонстрирует вычисляемое поле без дополнительного запроса.

2.3 ViewSet и роуты

# notes/views.py
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Note
from .serializers import NoteSerializer

class NoteViewSet(viewsets.ModelViewSet):
    """
    CRUD API для заметок.
    Список доступен только аутентифицированным пользователям,
    но каждый пользователь видит только свои записи.
    """
    serializer_class = NoteSerializer
    permission_classes = [permissions.IsAuthenticated]
    filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
    filterset_fields = ['day']
    search_fields = ['title', 'body', 'tags']
    ordering_fields = ['created_at', 'day']

    def get_queryset(self):
        # SELECT ... FROM notes_note WHERE owner_id = ?
        return Note.objects.filter(owner=self.request.user).select_related('owner')

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)
# notes/urls.py
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import NoteViewSet

router = DefaultRouter()
router.register(r'notes', NoteViewSet, basename='note')

urlpatterns = [
    path('api/', include(router.urls)),
]
  • select_related('owner') убирает N+1 при сериализации owner.username.
  • Фильтр по полю day позволяет быстро находить записи за конкретный день.

2.4 Миграции

$ python manage.py makemigrations notes
$ python manage.py migrate

Django автоматически создаст GIN‑индекс для JSONField (PostgreSQL ≥ 9.4) и обычный B‑tree‑индекс для day.

2.5 Сигналы: автоматическое логирование

# notes/signals.py
import logging
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from .models import Note

logger = logging.getLogger(__name__)

@receiver(post_save, sender=Note)
def log_note_saved(sender, instance, created, **kwargs):
    action = 'Created' if created else 'Updated'
    logger.info(f'{action} note {instance.id} by {instance.owner}')

@receiver(post_delete, sender=Note)
def log_note_deleted(sender, instance, **kwargs):
    logger.info(f'Deleted note {instance.id} by {instance.owner}')

Не забудьте импортировать сигналы в apps.py:

# notes/apps.py
from django.apps import AppConfig

class NotesConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'notes'

    def ready(self):
        import notes.signals  # noqa

2.6 Management‑команда: очистка старых записей

# notes/management/commands/cleannotes.py
from django.core.management.base import BaseCommand
from django.utils import timezone
from notes.models import Note
from datetime import timedelta

class Command(BaseCommand):
    help = "Удаляет заметки старше 30 дней."

    def handle(self, *args, **options):
        cutoff = timezone.now() - timedelta(days=30)
        deleted, _ = Note.objects.filter(created_at__lt=cutoff).delete()
        self.stdout.write(self.style.SUCCESS(f'Deleted {deleted} notes.'))

Запуск:

$ python manage.py cleannotes

Команда использует один DELETE запрос, благодаря индексу по created_at.


3. Нюансы, грабли и альтернативы

3.1 N+1 запросы

Проблема: При выводе списка заметок и их тегов часто вызывается отдельный запрос на каждый объект.

Решение:

Что Как реализовать
Owner select_related('owner') в get_queryset.
Tags JSONField хранит массив, дополнительный запрос не нужен.
Доп. связанные модели prefetch_related при необходимости.

3.2 Транзакции и атомарность

Создание и обновление заметки в рамках одной бизнес‑операции (например, запись и отправка email) должно быть атомарным:

from django.db import transaction

def create_note_with_notification(user, data):
    with transaction.atomic():
        note = Note.objects.create(owner=user, **data)
        send_notification_email(user.email, note)  # может бросить исключение
    return note

Если забыть transaction.atomic(), часть операции может выполниться, а часть – нет.

3.3 Индексация

  • Индекс owner + day ускоряет запрос filter(owner=..., day=...).
  • created_at уже имеет индекс (db_index=True), что делает удаление старых записей быстрым.
  • Для поиска по тегам (JSONField) добавьте GinIndex:
from django.contrib.postgres.indexes import GinIndex

class Meta:
    indexes = [
        GinIndex(fields=['tags'], name='tags_gin_idx'),
    ]

3.4 Кеширование

Для публичного списка последних 5 записей можно кешировать результат:

# notes/views.py (добавляем метод)
from django.core.cache import cache
from rest_framework.decorators import action
from rest_framework.response import Response

class NoteViewSet(viewsets.ModelViewSet):
    # ...

    @action(detail=False, methods=['get'], url_path='recent')
    def recent(self, request):
        cache_key = f'recent_notes_{request.user_id}'
        data = cache.get(cache_key)
        if not data:
            qs = self.get_queryset().order_by('-created_at')[:5]
            data = NoteSerializer(qs, many=True).data
            cache.set(cache_key, data, timeout=300)  # 5 минут
        return Response(data)

Кеш чистится автоматически, когда пользователь создаёт/удаляет заметку (можно добавить сигнал, вызывающий cache.delete).

3.5 Async‑вью (Django 4.2)

Если планируется масштабировать до тысяч одновременных запросов, можно переписать только список в async‑режиме:

# notes/views_async.py
from django.http import JsonResponse
from asgiref.sync import sync_to_async
from .models import Note

async def async_note_list(request):
    user = request.user
    notes = await sync_to_async(list)(
        Note.objects.filter(owner=user).order_by('-created_at')[:20]
    )
    data = [{'id': n.id, 'title': n.title, 'created_at': n.created_at.isoformat()} for n in notes]
    return JsonResponse(data, safe=False)
Плюсы Минусы
Не блокирует цикл событий, лучше при I/O‑нагрузке Требует ASGI‑сервер (uvicorn, daphne) и тестов на async.
Позволяет комбинировать с httpx‑запросами к другим сервисам Не ускорит чисто CPU‑операции, такие как JSON‑сериализация.

4. Вывод

  • Модели: используйте JSONField для гибких тегов, добавляйте «генерируемый» day‑столбец и составные индексы (owner, day) — это решает большинство запросов по дате без сканирования.
  • API: ModelViewSet + DjangoFilterBackend покрывает CRUD и поиск; select_related и prefetch_related убирают N+1.
  • Поддержка данных: сигнализируйте о изменениях, а management‑команда автоматически чистит старые записи, экономя место.
  • Оптимизации: GIN‑индекс для tags, кеширование часто запрашиваемых списков, атомарные транзакции и, при необходимости, async‑вью.
  • Грабли: забытый индекс — медленный DELETE; отсутствие transaction.atomic() — неконсистентные данные; неправильный ordering — плохие планы запросов.

Что можно улучшить

  • Перенести бизнес‑логику в слой сервиса (service‑objects) для лучшей тестируемости.
  • Добавить Celery‑задачу вместо синхронного management‑команды, если очистка должна происходить регулярно без простоя.
  • Ввести versioned API (DRF DefaultRouter + namespace) для будущего расширения функций.

С этими лайфхаками ваш сервис ежедневных заметок будет надёжен, быстрый и легко расширяемый. Happy coding!