Migration fix (#12808)

* Fix for Note migration

- Handles case where data migration is running on a DB which does not support RETURNING

* Run migration tests against all supported backends

* Fix typo

* Adjust CI workflows

* TZ migration fix

* Tweak migration test

* Adjust migration tests

* Ensure consistent ordering

* Bump API version

* Fix tests to allow non psql database support
This commit is contained in:
Oliver
2026-09-08 18:50:13 +10:00
committed by GitHub
parent 0a5b04b16d
commit 7cd74eecd4
7 changed files with 320 additions and 147 deletions
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 544
INVENTREE_API_VERSION = 545
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v545 -> 2026-09-08 : https://github.com/inventree/InvenTree/pull/12808
- Ensure consistent ordering of SSO options in API documentation
v544 -> 2026-09-07 : https://github.com/inventree/InvenTree/pull/12807
- Adds filtering by filename on the Attachment list API endpoint
+1 -1
View File
@@ -987,7 +987,7 @@ SOCIAL_BACKENDS = get_setting(
)
DEFAULT_SOCIAL = ['saml', 'openid_connect']
_SOCIAL_BACKENDS = {*DEFAULT_SOCIAL, *SOCIAL_BACKENDS}
_SOCIAL_BACKENDS = list(dict.fromkeys([*DEFAULT_SOCIAL, *SOCIAL_BACKENDS]))
# region auth
for app in _SOCIAL_BACKENDS: # pragma: no cover
@@ -117,7 +117,16 @@ def create_notes_batch(Note, NotesImage, content_type, model, instances, unlinke
batch_size=BATCH_SIZE,
)
notes_by_model_id = {instance.pk: note for instance, note in zip(instances, notes)}
# bulk_create() only populates the pk on the returned objects on backends
# that support RETURNING on bulk insert.
# So we re-fetch the notes we just created rather than trusting the returned objects.
notes_by_model_id = {
note.model_id: note
for note in Note.objects.filter(
model_type=content_type,
model_id__in=[instance.pk for instance in instances],
)
}
# Images directly linked to one of these instances
direct_images = list(
@@ -131,7 +140,8 @@ def create_notes_batch(Note, NotesImage, content_type, model, instances, unlinke
# Images not directly linked to any instance, but still referenced in the
# markdown content itself
embedded_images = []
for instance, note in zip(instances, notes):
for instance in instances:
note = notes_by_model_id[instance.pk]
matched = [
image for image in unlinked_images if image.image.url in instance.notes
]
@@ -172,7 +182,7 @@ def migrate_orphaned_images(Note, NotesImage, content_type, model):
model_ids = sorted({image.model_id for image in orphaned_images})
notes = Note.objects.bulk_create(
Note.objects.bulk_create(
[
Note(
title="Note",
@@ -186,7 +196,15 @@ def migrate_orphaned_images(Note, NotesImage, content_type, model):
batch_size=BATCH_SIZE,
)
notes_by_model_id = dict(zip(model_ids, notes))
# See the matching comment in create_notes_batch() - bulk_create() doesn't
# reliably return populated pks across all backends, so re-fetch the notes
# we just created rather than trusting the returned objects.
notes_by_model_id = {
note.model_id: note
for note in Note.objects.filter(
model_type=content_type, model_id__in=model_ids
)
}
for image in orphaned_images:
image.note = notes_by_model_id[image.model_id]
@@ -3,7 +3,9 @@
import datetime
from tqdm import tqdm
from django.conf import settings
from django.db import migrations
from django.utils import timezone
def set_creation_date(apps, schema_editor):
@@ -74,7 +76,13 @@ def set_creation_date(apps, schema_editor):
date_options = [make_aware(d) for d in raw_options if d is not None]
if date_options:
item.creation_date = min(date_options)
creation_date = min(date_options)
# Check if timezone-awarae datetimes are being used in the project settings
if not settings.USE_TZ:
creation_date = timezone.make_naive(creation_date, utc)
item.creation_date = creation_date
process_item(item)
progress.update(1)
+43 -27
View File
@@ -434,12 +434,13 @@ class TestCreationDateMigration(MigratorTestCase):
"""Create StockItem entries with varied data to exercise all backfill paths."""
import datetime
from django.conf import settings
from django.db import connection
Part = self.old_state.apps.get_model('part', 'part')
StockItemTracking = self.old_state.apps.get_model('stock', 'stockitemtracking')
utc = datetime.timezone.utc
utc = datetime.timezone.utc if settings.USE_TZ else None
part = Part.objects.create(
name='Migration Test Part', level=0, tree_id=1, lft=0, rght=0
@@ -455,19 +456,23 @@ class TestCreationDateMigration(MigratorTestCase):
Raw SQL also leaves updated=NULL (no DB-level default for auto_now), which
makes Scenario 6 a clean "no date sources available" case.
"""
insert_sql = """
INSERT INTO stock_stockitem
(part_id, quantity, level, tree_id, lft, rght,
status, delete_on_deplete, review_needed, is_building,
link, serial_int, barcode_data, barcode_hash)
VALUES (%s, 1, 0, 0, 0, 0, 10, false, false, false, '', 0, '', '')
"""
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO stock_stockitem
(part_id, quantity, level, tree_id, lft, rght,
status, delete_on_deplete, review_needed, is_building,
link, serial_int, barcode_data, barcode_hash)
VALUES (%s, 1, 0, 0, 0, 0, 10, false, false, false, '', 0, '', '')
RETURNING id
""",
[part.pk],
)
pk = cursor.fetchone()[0]
# MySQL has no RETURNING support at all (not even a syntax error
# workaround) - fall back to cursor.lastrowid there, and use
# RETURNING elsewhere since psycopg's cursor has no lastrowid.
if connection.features.can_return_rows_from_bulk_insert:
cursor.execute(insert_sql + 'RETURNING id', [part.pk])
pk = cursor.fetchone()[0]
else:
cursor.execute(insert_sql, [part.pk])
pk = cursor.lastrowid
if stocktake_date is not None:
cursor.execute(
'UPDATE stock_stockitem SET stocktake_date = %s WHERE id = %s',
@@ -545,12 +550,18 @@ class TestCreationDateMigration(MigratorTestCase):
"""Verify creation_date is correctly backfilled for each scenario."""
import datetime
from django.conf import settings
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
utc = datetime.timezone.utc
utc = datetime.timezone.utc if settings.USE_TZ else None
def at_utc(dt):
"""Normalise to UTC and strip sub-second precision for comparison."""
return dt.astimezone(utc).replace(microsecond=0)
return (
dt.astimezone(utc).replace(microsecond=0)
if settings.USE_TZ
else dt.replace(microsecond=0)
)
# Scenario 1: CREATED tracking entry → creation_date = entry date
item = StockItem.objects.get(pk=self.pk_s1)
@@ -610,19 +621,24 @@ class TestRemoveMpttFieldsMigration(MigratorTestCase):
def make_item(quantity, parent_id=None):
"""Insert via raw SQL to avoid duplicate status_custom_key ORM bug."""
insert_sql = """
INSERT INTO stock_stockitem
(part_id, quantity, level, tree_id, lft, rght,
status, delete_on_deplete, is_building,
link, serial_int, barcode_data, barcode_hash, parent_id)
VALUES (%s, %s, 0, 0, 0, 0, 10, false, false, '', 0, '', '', %s)
"""
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO stock_stockitem
(part_id, quantity, level, tree_id, lft, rght,
status, delete_on_deplete, is_building,
link, serial_int, barcode_data, barcode_hash, parent_id)
VALUES (%s, %s, 0, 0, 0, 0, 10, false, false, '', 0, '', '', %s)
RETURNING id
""",
[part.pk, quantity, parent_id],
)
return cursor.fetchone()[0]
# MySQL has no RETURNING support at all - fall back to
# cursor.lastrowid there, and use RETURNING elsewhere since
# psycopg's cursor has no lastrowid.
if connection.features.can_return_rows_from_bulk_insert:
cursor.execute(
insert_sql + 'RETURNING id', [part.pk, quantity, parent_id]
)
return cursor.fetchone()[0]
cursor.execute(insert_sql, [part.pk, quantity, parent_id])
return cursor.lastrowid
# Root stock item, with no parent
root_pk = make_item(100)