2025, Sep 26 15:18
Как аннотировать транзакции курсом в Django ORM: миграции, Subquery, Coalesce
Пошагово разбираем, как обогатить транзакции дневными курсами в Django: нормализовать дату миграциями и аннотировать queryset с Subquery и Coalesce без циклов.
Обогащать транзакционные данные дневными курсами фиата — типичная задача для проектов на Django с упором на аналитику. Сложность возрастает, когда в системе есть и нормализованная таблица с реальными datetime-значениями, и наследуемая таблица, где дата хранится строкой. Цель — выполнить обогащение на уровне queryset, а не в Python, сохранив скорость, единообразие и простоту поддержки.
Базовый подход на Python
Ниже — базовая логика на Python: мы перебираем транзакции, ищем дневной курс в Price, при отсутствии — берём из PriceTextBackup, затем агрегируем значения. Работает, но вся нагрузка остаётся в питоновских циклах: база не участвует ни в джоинах, ни в аннотациях строк.
for entry in transactions:
    day_marker = entry.timestamp.date()
    rate_obj = Price.objects.filter(fiat=cur_id, date=day_marker).first()
    if not rate_obj:
        rate_obj = PriceTextBackup.objects.filter(
            fiat=cur_id,
            date=day_marker.strftime("%d-%m-%Y")
        ).first()
    if rate_obj:
        if entry.amount > 0:
            subtotal = rate_obj.price * entry.amount
            price_list.append(subtotal)
            transaction_counter += entry.amount
Фрагмент шаблона показывает, как отображается обогащённое значение в фиате с всплывающей подсказкой при наведении:
{% if row.amount >= 0 %}
    <td onmouseover="showPopup({{ forloop.counter }}0000)"
        onmouseout="hidePopup()"
        class="text-success">{{ row.fiat_CHF|floatformat:2|intcomma }}</td>
{% else %}
    <td class="text-danger">{{ row.fiat_CHF|floatformat:2|intcomma }}</td>
{% endif %}
Почему подход через ORM‑queryset не работает «как есть»
Упираемся в форму данных. Price.date — это DateTimeField, а PriceTextBackup.date — строка. Пока одна сторона — CharField с датой в формате вроде %d-%m-%Y, надёжно аннотировать Transactions дневным курсом только средствами ORM не получится. Сопоставление меток времени с курсами по дням требует сравнения только даты. Пока даты не нормализованы, любое решение на queryset либо громоздко, либо медленно.
Сначала миграция: два безопасных пути
Самый чистый путь — поправить модель данных. Либо перенесите пригодные строки из PriceTextBackup в Price, либо преобразуйте поле даты в резервной таблице к реальному типу времени. Оба варианта легко реализуются через data migrations в Django.
Вариант 1: перенести пригодные строки из PriceTextBackup в Price
Создайте пустую миграцию и заполните Price строками, которые удаётся распарсить из резервной таблицы. Так Price останется единственным источником истины по дневным курсам.
manage.py makemigrations --empty my_appЗатем реализуйте миграцию следующим образом:
# Сгенерировано Django 5.2.0 2025-09-04 17:28
from django.db import migrations, models
from django.db.models import DateTimeField
from datetime import datetime
def seed_forward(apps, schema_editor):
    Price = apps.get_model('app_name', 'Price')
    PriceTextBackup = apps.get_model('app_name', 'PriceTextBackup')
    present = {
        (p.date.date(), p.fiat_id) for p in Price.objects.only('date', 'fiat_id')
    }
    stash = []
    for rec in PriceTextBackup.objects.all():
        parsed = None
        try:
            parsed = datetime.strptime(rec.date, '%d-%m-%Y').date()
        except ValueError:
            print('problem for {item.date!r}')
            continue
        key = (parsed, rec.fiat_id)
        if key not in present:
            present.add(key)
            stash.append(
                Price(price=rec.price, date=parsed, fiat_id=rec.fiat_id)
            )
        Price.objects.bulk_create(stash)
class Migration(migrations.Migration):
    dependencies = [
        ('app_name', '1234_previous_migration_file'),
    ]
    operations = [migrations.RunPython(seed_forward)]
Сначала выполните это на копии базы, чтобы проверить результат. После шага миграции запросы можно строить уже по единой таблице Price.
Вариант 2: преобразовать дату в резервной таблице в настоящий DateTimeField
Либо нормализуйте дату в PriceTextBackup, мигрировав строковое поле в DateTimeField. Начните с изменения модели:
from datetime import datetime
class PriceTextBackup(models.Model):
    date = models.DateTimeField(default=datetime(1970, 1, 1))
    price = models.FloatField()
    fiat = models.ForeignKey(Currencies, on_delete=models.DO_NOTHING)
Сгенерируйте миграцию, но пока не применяйте её:
python manage.py makemigrationsЗатем поправьте сгенерированную миграцию: распарсьте старые строки во временное поле, поменяйте поля местами и сохраните разобранные значения:
# Сгенерировано Django 5.2.0 2025-09-04 17:28
from django.db import migrations, models
from django.db.models import DateTimeField
from datetime import datetime
def convert_forward(apps, schema_editor):
    PriceTextBackup = apps.get_model('app_name', 'PriceTextBackup')
    buffer = []
    for row in PriceTextBackup.objects.iterator():
        buffer.append(row)
        row.date_as_date = datetime.strptime(row.date, '%d-%m-%Y')
        if len(buffer) > 100:
            PriceTextBackup.objects.bulk_update(
                buffer, fields=('date_as_date',)
            )
            buffer = []
    PriceTextBackup.objects.bulk_update(buffer, fields=('date_as_date',))
class Migration(migrations.Migration):
    dependencies = [
        ('app_name', '1234_previous_migration_file'),
    ]
    operations = [
        migrations.AddField(
            model_name='pricetextbackup',
            name='date_as_date',
            field=models.DateTimeField(
                blank=True, null=True, verbose_name='Date'
            ),
        ),
        migrations.RunPython(convert_forward),
        migrations.RemoveField(
            model_name='pricetextbackup',
            name='date',
        ),
        migrations.RenameField(
            model_name='currencyrates',
            old_name='date_as_date',
            new_name='date',
        ),
        migrations.AddField(
            model_name='pricetextbackup',
            name='date',
            field=models.DateTimeField(verbose_name='Date'),
        ),
    ]
Как и прежде, проверьте всё на копии базы. Если какие-то строки не соответствуют формату %d-%m-%Y, их придётся разбирать иначе либо исключить непригодные записи.
Запросы с Subquery и Coalesce
Когда курсы нормализованы, можно аннотировать транзакции соответствующим дневным значением прямо в queryset. Если источником истины служит только Price, используйте Subquery: ищите по дате и валюте и берите первый результат:
from django.db.models import OuterRef, Subquery
currency_id = 1234
Transactions.objects.annotate(
    price=Subquery(
        Price.objects.filter(
            date__date=OuterRef('date__date'),
            fiat_id=currency_id,
        ).values('price')[:1]
    )
)
Если обе таблицы остаются и нужен запасной источник, оберните две Subquery в Coalesce: второй будет применён лишь тогда, когда первый не дал совпадения:
from django.db.models import OuterRef, Subquery
from django.db.models.functions import Coalesce
currency_id = 1234
Transactions.objects.annotate(
    price=Coalesce(
        Subquery(
            Price.objects.filter(
                date__date=OuterRef('date__date'),
                fiat_id=currency_id,
            ).values('price')[:1]
        ),
        Subquery(
            PriceTextBackup.objects.filter(
                date__date=OuterRef('date__date'),
                fiat_id=currency_id,
            ).values('price')[:1]
        ),
    ),
)
Аннотированное поле price доступно у каждого экземпляра Transaction, возвращённого queryset. Это не колонка в базе: значение вычисляется в SELECT и может выводиться в шаблонах так же, как обычные поля.
Зачем это нужно
Перенос обогащения на уровень базы убирает питоновские циклы по потенциально большим наборам и предотвращает шаблон N+1. Нормализация времени обеспечивает единообразные сравнения и упрощает работу с частями даты и агрегатами. Наконец, единая авторитетная таблица дневных курсов сокращает разветвление кода и снимает двусмысленность между источниками.
Практическое резюме
Начните с нормализации курсов. Либо скопируйте корректные строки из PriceTextBackup в Price с помощью дата-миграции, либо преобразуйте PriceTextBackup.date в настоящий DateTimeField, разобрав старые строки. Затем аннотируйте Transactions через Subquery, при необходимости добавив Coalesce как резерв. В итоге вы получите чистое, управляемое базой обогащение, которое сразу работает в вьюхах и шаблонах без дополнительных построчных запросов.
Статья основана на вопросе на StackOverflow от donbolli и ответе willeM_ Van Onsem.