Skip to content

Implement TypeID for InvenTree models#580

Open
matmair wants to merge 3 commits intomasterfrom
implement-typeid-metadata-id-14006765293098350126
Open

Implement TypeID for InvenTree models#580
matmair wants to merge 3 commits intomasterfrom
implement-typeid-metadata-id-14006765293098350126

Conversation

@matmair
Copy link
Owner

@matmair matmair commented Mar 20, 2026

Implemented a new model mixin TypeIDMixin that adds a globally unique, time-sortable metadata_id field to models. This mixin uses the typeid-python library and automatically generates identifiers with prefixes based on the lowercase model name. The mixin has been integrated into the core InvenTreeModel and InvenTreeTree base classes, ensuring that most models in the system now have a unique TypeID. Migrations have been generated for all affected apps. The field was named metadata_id to prevent conflicts with Django's conventional object_id used in Generic Foreign Keys.


PR created automatically by Jules for task 14006765293098350126 started by @matmair

Summary by CodeRabbit

  • New Features

    • Added a nullable, globally unique "Metadata ID" to many models and enabled automatic Metadata ID generation for new records.
  • Bug Fixes

    • Prevented metadata IDs from being copied onto newly created or split stock items.
  • Chores

    • Added new backend packages to support Metadata ID generation.

- Add `typeid-python` to backend requirements.
- Create `TypeIDMixin` in `InvenTree.models` which adds a `metadata_id` field.
- The `metadata_id` field uses `typeid-python` to generate a globally unique, time-sortable identifier with a prefix derived from the model name.
- Include `TypeIDMixin` in `InvenTreeModel` and `InvenTreeTree` base classes.
- Generate migrations for all affected apps.

Note: The field was named `metadata_id` instead of `object_id` to avoid name collisions with existing fields used for Django Generic Foreign Keys.

Co-authored-by: matmair <66015116+matmair@users.noreply.github.com>
@google-labs-jules
Copy link

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@coderabbitai
Copy link

coderabbitai bot commented Mar 20, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d571df90-90ac-4310-bc4d-f86f62f8cb20

📥 Commits

Reviewing files that changed from the base of the PR and between ffe44be and b5ff484.

📒 Files selected for processing (1)
  • src/backend/InvenTree/InvenTree/models.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/backend/InvenTree/InvenTree/models.py

📝 Walkthrough

Walkthrough

Adds an abstract TypeIDMixin providing a nullable, unique, non-editable metadata_id with generation/ensure logic and integrates it into base models; adds metadata_id fields via migrations across many apps; prevents metadata_id propagation in stock serial/split operations.

Changes

Cohort / File(s) Summary
Core Model System
src/backend/InvenTree/InvenTree/models.py
Add abstract TypeIDMixin (field metadata_id, get_typeid_prefix(), generate_typeid(), ensure_metadata_id()), override validate_unique() and save(); mix into InvenTreeModel and InvenTreeTree.
Stock Model Adjustments
src/backend/InvenTree/stock/models.py
Remove/clear metadata_id when bulk-creating serialised stock and when splitting stock (data.pop('metadata_id', None), new_stock.metadata_id = None).
Migrations — Build / Common / Company / Order / Part / Report / Stock
src/backend/InvenTree/build/migrations/0059_build_metadata_id_builditem_metadata_id_and_more.py, src/backend/InvenTree/common/migrations/0042_attachment_metadata_id_barcodescanresult_metadata_id_and_more.py, src/backend/InvenTree/company/migrations/0080_address_metadata_id_company_metadata_id_and_more.py, src/backend/InvenTree/order/migrations/0116_purchaseorder_metadata_id_and_more.py, src/backend/InvenTree/part/migrations/0148_bomitem_metadata_id_bomitemsubstitute_metadata_id_and_more.py, src/backend/InvenTree/report/migrations/0032_labeltemplate_metadata_id_reporttemplate_metadata_id.py, src/backend/InvenTree/stock/migrations/0119_stockitem_metadata_id_and_more.py
Add nullable, unique, non-editable metadata_id CharField (max_length=100) to many models across apps (explicit AddField operations; no backfill logic included).
Dependency & Lockfiles
src/backend/requirements.in, src/backend/requirements.txt, src/backend/requirements-3.14.txt
Introduce typeid-python and uuid-utils pins and update compiled/lockfiles to include typeid-python==0.3.9 and uuid-utils==0.14.1.

Sequence Diagram(s)

sequenceDiagram
  participant Model as Model Instance
  participant TypeID as TypeID Lib
  participant DB as Database

  Model->>Model: save()
  Model->>Model: ensure_metadata_id()
  alt metadata_id missing or duplicate
    Model->>TypeID: generate_typeid()
    TypeID-->>Model: new_typeid
    Model->>DB: exists(metadata_id=?)
    DB-->>Model: exists? (true/false)
    alt exists true
      Model->>TypeID: generate_typeid() (retry)
      TypeID-->>Model: new_typeid
    end
    Model->>Model: set metadata_id
  end
  Model->>Model: validate_unique()
  Model->>DB: super().save() (write)
  DB-->>Model: saved
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped through models late tonight,
Sprinkled TypeIDs soft and bright,
Cleared the bits that shouldn’t roam,
New fields stretch across the home,
A happy rabbit hums with delight ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Implement TypeID for InvenTree models' directly and clearly describes the main objective: adding TypeID support to InvenTree models via the new TypeIDMixin and metadata_id field.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch implement-typeid-metadata-id-14006765293098350126
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/backend/InvenTree/InvenTree/models.py (1)

497-502: Consider backfill strategy for existing records.

The save() method only populates metadata_id when the field is empty during a save operation. Existing database records will retain NULL values until they are explicitly updated/saved. Depending on requirements, you may want to:

  1. Add a data migration with RunPython to backfill existing records
  2. Provide a management command to populate metadata_id for existing records
  3. Document that pre-existing records will have NULL until modified

If this is intentional (e.g., to avoid long migration times on large tables), consider documenting the expected behavior.

Would you like me to help generate a data migration or management command to backfill existing records?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/InvenTree/InvenTree/models.py` around lines 497 - 502, The save()
override in models.py sets metadata_id via generate_typeid() only when a record
is saved, leaving existing rows NULL; add a backfill strategy: create a Django
data migration (RunPython) that imports the model(s) with the save() override
and sets metadata_id = instance.generate_typeid() for records where metadata_id
is NULL (use bulk update or chunking to avoid memory issues), or alternatively
add a management command that performs the same backfill so ops can run it
manually; ensure the migration/command references the model's save(),
metadata_id field, and generate_typeid() and documents the behavior if you opt
not to backfill automatically.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/backend/InvenTree/report/migrations/0032_labeltemplate_metadata_id_reporttemplate_metadata_id.py`:
- Around line 12-37: The migration adds metadata_id fields on LabelTemplate and
ReportTemplate but leaves them nullable, so existing rows can stay NULL; add a
data migration (using migrations.RunPython in the same app) that locates all
LabelTemplate and ReportTemplate records and sets metadata_id to a generated
UUID (e.g., uuid.uuid4().hex or str(uuid.uuid4())) for any instance where
metadata_id is null, and ensure the forward function is idempotent; then create
a follow-up schema migration to AlterField metadata_id on both models to
null=False (and keep unique=True) so the DB enforces non-null uniqueness.
Reference the migration file
0032_labeltemplate_metadata_id_reporttemplate_metadata_id.py, the models
LabelTemplate and ReportTemplate, and the field name metadata_id when
implementing the RunPython backfill and subsequent AlterField migration.

In `@src/backend/requirements.in`:
- Around line 73-75: The unpinned package entry "typeid-python" is listed under
the "# Pins" section but not pinned; either pin it to a specific version (e.g.,
change "typeid-python" to "typeid-python==<version>") or move the unpinned
"typeid-python" line out of the "# Pins" block into the main dependency list
above; update the requirements.in entry accordingly and re-run your dependency
tooling (e.g., pip-compile) so the lockfile reflects the change.

---

Nitpick comments:
In `@src/backend/InvenTree/InvenTree/models.py`:
- Around line 497-502: The save() override in models.py sets metadata_id via
generate_typeid() only when a record is saved, leaving existing rows NULL; add a
backfill strategy: create a Django data migration (RunPython) that imports the
model(s) with the save() override and sets metadata_id =
instance.generate_typeid() for records where metadata_id is NULL (use bulk
update or chunking to avoid memory issues), or alternatively add a management
command that performs the same backfill so ops can run it manually; ensure the
migration/command references the model's save(), metadata_id field, and
generate_typeid() and documents the behavior if you opt not to backfill
automatically.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6f1a3d8e-08ef-4329-ba84-8e6c6e4d020d

📥 Commits

Reviewing files that changed from the base of the PR and between a64b15a and a753095.

📒 Files selected for processing (11)
  • src/backend/InvenTree/InvenTree/models.py
  • src/backend/InvenTree/build/migrations/0059_build_metadata_id_builditem_metadata_id_and_more.py
  • src/backend/InvenTree/common/migrations/0042_attachment_metadata_id_barcodescanresult_metadata_id_and_more.py
  • src/backend/InvenTree/company/migrations/0080_address_metadata_id_company_metadata_id_and_more.py
  • src/backend/InvenTree/order/migrations/0116_purchaseorder_metadata_id_and_more.py
  • src/backend/InvenTree/part/migrations/0148_bomitem_metadata_id_bomitemsubstitute_metadata_id_and_more.py
  • src/backend/InvenTree/report/migrations/0032_labeltemplate_metadata_id_reporttemplate_metadata_id.py
  • src/backend/InvenTree/stock/migrations/0119_stockitem_metadata_id_and_more.py
  • src/backend/requirements-3.14.txt
  • src/backend/requirements.in
  • src/backend/requirements.txt

Comment on lines +12 to +37
operations = [
migrations.AddField(
model_name="labeltemplate",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
migrations.AddField(
model_name="reporttemplate",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Django (5.x) with PostgreSQL, does a field defined as unique=True and null=True allow multiple rows with NULL, and what is the recommended migration strategy to backfill then enforce non-null uniqueness?

💡 Result:

Yes, in Django 5.x with PostgreSQL, a field defined as unique=True and null=True allows multiple rows with NULL values. This is standard PostgreSQL behavior for unique constraints: NULL values are not considered equal to each other, so multiple NULLs do not violate uniqueness. Django's field options explicitly note that null=True is required for CharField with unique=True and blank=True to avoid unique constraint violations when saving multiple blank objects, confirming multiple NULLs are permitted. The recommended migration strategy to backfill unique non-NULL values and then enforce non-null uniqueness on an existing table follows Django's official guidance for adding/modifying unique non-nullable fields: 1. Update the model field to null=True (if not already), run python manage.py makemigrations to create an AlterField migration adding null support. Apply with python manage.py migrate. This allows NULLs without violating the existing unique constraint. 2. Create an empty data migration: python manage.py makemigrations yourapp --empty. Edit it to include a RunPython operation that backfills unique values for all NULL rows (e.g., generate UUIDs, slugs, or sequential values ensuring uniqueness). Example: def backfill_unique_field(apps, schema_editor): Model = apps.get_model('yourapp', 'YourModel') for obj in Model.objects.filter(your_fieldisnull=True): obj.your_field = generate_unique_value(obj) # Custom function obj.save class Migration(migrations.Migration): dependencies = [('yourapp', 'previous_migration')] operations = [migrations.RunPython(backfill_unique_field)] Apply the migration. 3. Update the model to null=False, run python manage.py makemigrations to create a final AlterField migration removing null support and fully enforcing the unique constraint. Apply with python manage.py migrate. For large tables, set atomic=False on the data migration to avoid transaction limits. Always test on a copy and verify no duplicate values exist post-backfill before finalizing.

Citations:


🏁 Script executed:

#!/bin/bash
# List migrations in the report migrations directory to check for follow-ups
ls -la src/backend/InvenTree/report/migrations/ | grep -E "\.py$"

Repository: matmair/InvenTree

Length of output: 2716


🏁 Script executed:

#!/bin/bash
# Check the content of migrations after 0032 to see if there's a backfill step
cd src/backend/InvenTree/report/migrations || exit 1
# Find all migration files with numbers >= 0032
for f in [0-9]*.py; do
  num=$(echo "$f" | sed 's/[^0-9].*//')
  if [ "$num" -ge 32 ] 2>/dev/null; then
    echo "=== $f ===" 
    head -50 "$f"
    echo ""
  fi
done

Repository: matmair/InvenTree

Length of output: 1132


Add a data migration to backfill metadata_id before enforcing non-null uniqueness.

With null=True and unique=True, existing rows can remain NULL indefinitely. PostgreSQL allows multiple NULL values in unique constraints, so the field cannot serve as a "Globally unique identifier" for legacy data without a backfill step.

Add a data migration to populate metadata_id for all existing rows (e.g., generate UUIDs), then create a follow-up migration to alter the field to null=False and fully enforce uniqueness.

🧰 Tools
🪛 Ruff (0.15.6)

[warning] 12-37: Mutable default value for class attribute

(RUF012)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/backend/InvenTree/report/migrations/0032_labeltemplate_metadata_id_reporttemplate_metadata_id.py`
around lines 12 - 37, The migration adds metadata_id fields on LabelTemplate and
ReportTemplate but leaves them nullable, so existing rows can stay NULL; add a
data migration (using migrations.RunPython in the same app) that locates all
LabelTemplate and ReportTemplate records and sets metadata_id to a generated
UUID (e.g., uuid.uuid4().hex or str(uuid.uuid4())) for any instance where
metadata_id is null, and ensure the forward function is idempotent; then create
a follow-up schema migration to AlterField metadata_id on both models to
null=False (and keep unique=True) so the DB enforces non-null uniqueness.
Reference the migration file
0032_labeltemplate_metadata_id_reporttemplate_metadata_id.py, the models
LabelTemplate and ReportTemplate, and the field name metadata_id when
implementing the RunPython backfill and subsequent AlterField migration.

Comment on lines 73 to +75
# Pins
xmlsec==1.3.17
typeid-python
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether entries under '# Pins' are pinned with '=='
awk '
/^# Pins/{in_pins=1; next}
in_pins && /^#/ {next}
in_pins && NF==0 {next}
in_pins {
  if ($1 !~ /==/) print "Unpinned in Pins:", $0
}' src/backend/requirements.in

Repository: matmair/InvenTree

Length of output: 92


🏁 Script executed:

# Check the context around lines 73-75 and overall file structure
sed -n '60,80p' src/backend/requirements.in

Repository: matmair/InvenTree

Length of output: 474


Pin typeid-python or move it out of the # Pins section.

typeid-python is unpinned while under # Pins, which contradicts the section's intent. Pin it to a specific version (e.g., typeid-python==<version>) or move it to the main dependency list above.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/requirements.in` around lines 73 - 75, The unpinned package entry
"typeid-python" is listed under the "# Pins" section but not pinned; either pin
it to a specific version (e.g., change "typeid-python" to
"typeid-python==<version>") or move the unpinned "typeid-python" line out of the
"# Pins" block into the main dependency list above; update the requirements.in
entry accordingly and re-run your dependency tooling (e.g., pip-compile) so the
lockfile reflects the change.

- Update `TypeIDMixin` to generate a new `metadata_id` if the object is new and the ID already exists (e.g. during object duplication).
- Clear `metadata_id` in `StockItem.splitStock` to trigger new ID generation.
- Clear `metadata_id` in `StockItem._create_serial_numbers` to avoid duplicate IDs during bulk serialization.
- Add `validate_unique` to `TypeIDMixin` to ensure IDs are populated before uniqueness checks.

Co-authored-by: matmair <66015116+matmair@users.noreply.github.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 885b2436-5694-42d9-8d77-944747fa1720

📥 Commits

Reviewing files that changed from the base of the PR and between a753095 and ffe44be.

📒 Files selected for processing (9)
  • src/backend/InvenTree/InvenTree/models.py
  • src/backend/InvenTree/build/migrations/0059_build_metadata_id_builditem_metadata_id_and_more.py
  • src/backend/InvenTree/common/migrations/0042_attachment_metadata_id_barcodescanresult_metadata_id_and_more.py
  • src/backend/InvenTree/company/migrations/0080_address_metadata_id_company_metadata_id_and_more.py
  • src/backend/InvenTree/order/migrations/0116_purchaseorder_metadata_id_and_more.py
  • src/backend/InvenTree/part/migrations/0148_bomitem_metadata_id_bomitemsubstitute_metadata_id_and_more.py
  • src/backend/InvenTree/report/migrations/0032_labeltemplate_metadata_id_reporttemplate_metadata_id.py
  • src/backend/InvenTree/stock/migrations/0119_stockitem_metadata_id_and_more.py
  • src/backend/InvenTree/stock/models.py

Comment on lines +12 to +133
operations = [
migrations.AddField(
model_name="purchaseorder",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
migrations.AddField(
model_name="purchaseorderextraline",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
migrations.AddField(
model_name="purchaseorderlineitem",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
migrations.AddField(
model_name="returnorder",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
migrations.AddField(
model_name="returnorderextraline",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
migrations.AddField(
model_name="returnorderlineitem",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
migrations.AddField(
model_name="salesorder",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
migrations.AddField(
model_name="salesorderextraline",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
migrations.AddField(
model_name="salesorderlineitem",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
migrations.AddField(
model_name="salesordershipment",
name="metadata_id",
field=models.CharField(
editable=False,
help_text="Globally unique identifier",
max_length=100,
null=True,
unique=True,
verbose_name="Metadata ID",
),
),
]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Backfill existing rows so metadata_id is actually present on legacy records

This migration only adds nullable unique columns; existing records remain NULL after migration. If consumers expect every order entity to have a TypeID, this creates a data gap for pre-existing rows (even though new rows will auto-generate IDs). Please add a follow-up RunPython data migration to populate metadata_id for existing rows in each model before relying on it as globally available.

🧰 Tools
🪛 Ruff (0.15.6)

[warning] 12-133: Mutable default value for class attribute

(RUF012)

- Refactor `ensure_metadata_id` to satisfy Ruff style rules.
- Maintain fix for duplicate `metadata_id` generation during object duplication and bulk creation.

Co-authored-by: matmair <66015116+matmair@users.noreply.github.com>
@matmair
Copy link
Owner Author

matmair commented Mar 20, 2026

@jules rename the new field to object_id and add a one-time backfilling action to all models that contain the new field


return str(TypeID(prefix=self.get_typeid_prefix()))

metadata_id = models.CharField(
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename the new field to object_id

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a one-time backfilling action to all models that contain the new field

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant