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
+241
View File
@@ -0,0 +1,241 @@
# Data migration unit tests, run against every supported database backend
name: Migration Tests
on:
push:
branches-ignore: ["l10*", "dependabot/**", "backport/**"]
pull_request:
branches-ignore: ["l10*"]
env:
python_version: 3.12
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_MEDIA_ROOT: /home/runner/work/InvenTree/test_inventree_media
INVENTREE_STATIC_ROOT: /home/runner/work/InvenTree/test_inventree_static
INVENTREE_BACKUP_DIR: /home/runner/work/InvenTree/test_inventree_backup
INVENTREE_SITE_URL: http://localhost:8000
permissions:
contents: read
jobs:
paths-filter:
name: Filter
runs-on: ubuntu-latest
outputs:
migrations: ${{ steps.filter.outputs.migrations }}
force: ${{ steps.force.outputs.force }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
migrations:
- '**/test_migrations.py'
- '**/*migrations.py'
- '**/migrations/**'
- '.github/workflows**'
- 'src/backend/requirements.txt'
- name: Is CI being forced?
run: echo "force=true" >> $GITHUB_OUTPUT
id: force
if: |
contains(github.event.pull_request.labels.*.name, 'dependency') ||
contains(github.event.pull_request.labels.*.name, 'full-run')
sqlite:
name: Tests - Migrations [SQLite]
runs-on: ubuntu-latest
needs: paths-filter
if: ${{ (needs.paths-filter.outputs.force == 'true') || (github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true') }}
env:
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
INVENTREE_DEBUG: true
INVENTREE_LOG_LEVEL: WARNING
INVENTREE_PLUGINS_ENABLED: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils
dev-install: true
update: true
- name: Run Tests
run: invoke dev.test --check --migrations --report --coverage --translations
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: migrations-sqlite
mysql:
name: Tests - Migrations [MySQL]
runs-on: ubuntu-24.04
needs: paths-filter
if: ${{ (needs.paths-filter.outputs.force == 'true') || (github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true') }}
env:
INVENTREE_DB_ENGINE: django.db.backends.mysql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: root
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 3306
INVENTREE_DEBUG: true
INVENTREE_LOG_LEVEL: WARNING
INVENTREE_PLUGINS_ENABLED: false
services:
mysql:
image: mysql:9
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: inventree
MYSQL_USER: inventree
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
ports:
- 3306:3306
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libmysqlclient-dev
pip-dependency: mysqlclient
dev-install: true
update: true
- name: Run Tests
run: invoke dev.test --check --migrations --report --coverage --translations
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: migrations-mysql
postgresql:
name: Tests - Migrations [PostgreSQL]
runs-on: ubuntu-latest
needs: paths-filter
if: ${{ (needs.paths-filter.outputs.force == 'true') || (github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true') }}
env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: false
INVENTREE_LOG_LEVEL: WARNING
INVENTREE_PLUGINS_ENABLED: false
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: inventree
POSTGRES_PASSWORD: password
ports:
- 5432:5432
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libpq-dev
pip-dependency: psycopg
dev-install: true
update: true
- name: Run Tests
run: invoke dev.test --check --migrations --report --coverage --translations
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: migrations-postgresql
migrations-checks:
name: Tests - Database Migrations
runs-on: ubuntu-latest
needs: paths-filter
if: ${{ (needs.paths-filter.outputs.force == 'true') || (github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true') }}
env:
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
INVENTREE_DEBUG: true
INVENTREE_LOG_LEVEL: WARNING
INVENTREE_PLUGINS_ENABLED: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
name: Checkout Code
- name: Environment Setup
uses: ./.github/actions/setup
with:
install: true
- name: Fetch Database
run: git clone --depth 1 https://github.com/inventree/test-db ./test-db
- name: 0.10.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.10.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.11.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.11.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.13.5 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.13.5.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.16.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.16.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.17.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.17.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
-113
View File
@@ -33,7 +33,6 @@ jobs:
outputs: outputs:
server: ${{ steps.filter.outputs.server }} server: ${{ steps.filter.outputs.server }}
migrations: ${{ steps.filter.outputs.migrations }}
frontend: ${{ steps.filter.outputs.frontend }} frontend: ${{ steps.filter.outputs.frontend }}
api: ${{ steps.filter.outputs.api }} api: ${{ steps.filter.outputs.api }}
force: ${{ steps.force.outputs.force }} force: ${{ steps.force.outputs.force }}
@@ -55,11 +54,6 @@ jobs:
- 'src/backend/InvenTree/**' - 'src/backend/InvenTree/**'
- 'src/backend/requirements.txt' - 'src/backend/requirements.txt'
- 'src/backend/requirements-dev.txt' - 'src/backend/requirements-dev.txt'
migrations:
- '**/test_migrations.py'
- '**/migrations/**'
- '.github/workflows**'
- 'src/backend/requirements.txt'
api: api:
- 'src/backend/InvenTree/InvenTree/api_version.py' - 'src/backend/InvenTree/InvenTree/api_version.py'
frontend: frontend:
@@ -559,113 +553,6 @@ jobs:
- name: Data Export Test - name: Data Export Test
uses: ./.github/actions/migration uses: ./.github/actions/migration
migration-tests:
name: Tests - Migrations [PostgreSQL]
runs-on: ubuntu-latest
needs: paths-filter
if: ${{ (needs.paths-filter.outputs.force == 'true') || (github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true') }}
env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: False
INVENTREE_LOG_LEVEL: WARNING
INVENTREE_PLUGINS_ENABLED: false
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: inventree
POSTGRES_PASSWORD: password
ports:
- 5432:5432
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libpq-dev
pip-dependency: psycopg
dev-install: true
update: true
- name: Run Tests
run: invoke dev.test --check --migrations --report --coverage --translations
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: migrations
migrations-checks:
name: Tests - Full Migration [SQLite]
runs-on: ubuntu-latest
needs: paths-filter
if: ${{ (needs.paths-filter.outputs.force == 'true') || (github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true') }}
env:
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
INVENTREE_DEBUG: true
INVENTREE_LOG_LEVEL: WARNING
INVENTREE_PLUGINS_ENABLED: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
name: Checkout Code
- name: Environment Setup
uses: ./.github/actions/setup
with:
install: true
- name: Fetch Database
run: git clone --depth 1 https://github.com/inventree/test-db ./test-db
- name: 0.10.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.10.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.11.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.11.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.13.5 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.13.5.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.16.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.16.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.17.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.17.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
zizmor: zizmor:
name: Security [Zizmor] name: Security [Zizmor]
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v544 -> 2026-09-07 : https://github.com/inventree/InvenTree/pull/12807
- Adds filtering by filename on the Attachment list API endpoint - 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'] DEFAULT_SOCIAL = ['saml', 'openid_connect']
_SOCIAL_BACKENDS = {*DEFAULT_SOCIAL, *SOCIAL_BACKENDS} _SOCIAL_BACKENDS = list(dict.fromkeys([*DEFAULT_SOCIAL, *SOCIAL_BACKENDS]))
# region auth # region auth
for app in _SOCIAL_BACKENDS: # pragma: no cover 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, 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 # Images directly linked to one of these instances
direct_images = list( 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 # Images not directly linked to any instance, but still referenced in the
# markdown content itself # markdown content itself
embedded_images = [] embedded_images = []
for instance, note in zip(instances, notes): for instance in instances:
note = notes_by_model_id[instance.pk]
matched = [ matched = [
image for image in unlinked_images if image.image.url in instance.notes 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}) model_ids = sorted({image.model_id for image in orphaned_images})
notes = Note.objects.bulk_create( Note.objects.bulk_create(
[ [
Note( Note(
title="Note", title="Note",
@@ -186,7 +196,15 @@ def migrate_orphaned_images(Note, NotesImage, content_type, model):
batch_size=BATCH_SIZE, 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: for image in orphaned_images:
image.note = notes_by_model_id[image.model_id] image.note = notes_by_model_id[image.model_id]
@@ -3,7 +3,9 @@
import datetime import datetime
from tqdm import tqdm from tqdm import tqdm
from django.conf import settings
from django.db import migrations from django.db import migrations
from django.utils import timezone
def set_creation_date(apps, schema_editor): 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] date_options = [make_aware(d) for d in raw_options if d is not None]
if date_options: 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) process_item(item)
progress.update(1) progress.update(1)
+32 -16
View File
@@ -434,12 +434,13 @@ class TestCreationDateMigration(MigratorTestCase):
"""Create StockItem entries with varied data to exercise all backfill paths.""" """Create StockItem entries with varied data to exercise all backfill paths."""
import datetime import datetime
from django.conf import settings
from django.db import connection from django.db import connection
Part = self.old_state.apps.get_model('part', 'part') Part = self.old_state.apps.get_model('part', 'part')
StockItemTracking = self.old_state.apps.get_model('stock', 'stockitemtracking') 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( part = Part.objects.create(
name='Migration Test Part', level=0, tree_id=1, lft=0, rght=0 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 Raw SQL also leaves updated=NULL (no DB-level default for auto_now), which
makes Scenario 6 a clean "no date sources available" case. makes Scenario 6 a clean "no date sources available" case.
""" """
with connection.cursor() as cursor: insert_sql = """
cursor.execute(
"""
INSERT INTO stock_stockitem INSERT INTO stock_stockitem
(part_id, quantity, level, tree_id, lft, rght, (part_id, quantity, level, tree_id, lft, rght,
status, delete_on_deplete, review_needed, is_building, status, delete_on_deplete, review_needed, is_building,
link, serial_int, barcode_data, barcode_hash) link, serial_int, barcode_data, barcode_hash)
VALUES (%s, 1, 0, 0, 0, 0, 10, false, false, false, '', 0, '', '') VALUES (%s, 1, 0, 0, 0, 0, 10, false, false, false, '', 0, '', '')
RETURNING id """
""", with connection.cursor() as cursor:
[part.pk], # 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] pk = cursor.fetchone()[0]
else:
cursor.execute(insert_sql, [part.pk])
pk = cursor.lastrowid
if stocktake_date is not None: if stocktake_date is not None:
cursor.execute( cursor.execute(
'UPDATE stock_stockitem SET stocktake_date = %s WHERE id = %s', '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.""" """Verify creation_date is correctly backfilled for each scenario."""
import datetime import datetime
from django.conf import settings
StockItem = self.new_state.apps.get_model('stock', 'stockitem') 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): def at_utc(dt):
"""Normalise to UTC and strip sub-second precision for comparison.""" """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 # Scenario 1: CREATED tracking entry → creation_date = entry date
item = StockItem.objects.get(pk=self.pk_s1) item = StockItem.objects.get(pk=self.pk_s1)
@@ -610,19 +621,24 @@ class TestRemoveMpttFieldsMigration(MigratorTestCase):
def make_item(quantity, parent_id=None): def make_item(quantity, parent_id=None):
"""Insert via raw SQL to avoid duplicate status_custom_key ORM bug.""" """Insert via raw SQL to avoid duplicate status_custom_key ORM bug."""
with connection.cursor() as cursor: insert_sql = """
cursor.execute(
"""
INSERT INTO stock_stockitem INSERT INTO stock_stockitem
(part_id, quantity, level, tree_id, lft, rght, (part_id, quantity, level, tree_id, lft, rght,
status, delete_on_deplete, is_building, status, delete_on_deplete, is_building,
link, serial_int, barcode_data, barcode_hash, parent_id) link, serial_int, barcode_data, barcode_hash, parent_id)
VALUES (%s, %s, 0, 0, 0, 0, 10, false, false, '', 0, '', '', %s) VALUES (%s, %s, 0, 0, 0, 0, 10, false, false, '', 0, '', '', %s)
RETURNING id """
""", with connection.cursor() as cursor:
[part.pk, quantity, parent_id], # 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] return cursor.fetchone()[0]
cursor.execute(insert_sql, [part.pk, quantity, parent_id])
return cursor.lastrowid
# Root stock item, with no parent # Root stock item, with no parent
root_pk = make_item(100) root_pk = make_item(100)