mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-11 06:59:04 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user