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 как резерв. В итоге вы получите чистое, управляемое базой обогащение, которое сразу работает в вьюхах и шаблонах без дополнительных построчных запросов.