diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb687ad6a..ed49c6d873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +- [#11971](https://github.com/inventree/InvenTree/pull/11971) is a major refactor of how notes are handled. Notes are now stored in a separate database table (in line with how attachments are handled), and each model instance can have multiple notes associated with it. The `notes` field has been removed from the individual models (and their associated API endpoints), and notes are now accessed via the new `/api/note/` endpoint. Existing notes data (and any embedded images) are automatically migrated to the new notes table, with the markdown content converted to HTML. Any external client applications which read or write the `notes` field via the API will need to be updated to use the new endpoint. - [#12223](https://github.com/inventree/InvenTree/pull/12223) removes support for python 3.11 and stops providing packages for Debian 11 and Ubuntu 20.04. ### Added diff --git a/src/backend/InvenTree/common/test_migrations.py b/src/backend/InvenTree/common/test_migrations.py index c6e62cefef..f4ede60bef 100644 --- a/src/backend/InvenTree/common/test_migrations.py +++ b/src/backend/InvenTree/common/test_migrations.py @@ -290,6 +290,237 @@ class TestAttachmentThumbnailMigration(MigratorTestCase): self.assertFalse(att.thumbnail) +class TestNoteMigrations(MigratorTestCase): + """Test data migration of legacy 'notes' fields to the new Note model. + + - Migration common.0047 copies existing 'notes' field data into the Note model, + converting the markdown content to HTML, and links any associated NotesImage objects. + - Migration common.0048 removes the legacy 'model_type' and 'model_id' fields from NotesImage. + """ + + # Note: these targets must match the dependencies of the data migration (common.0047), + # to ensure that the migration plan is truncated *before* the data migration is applied + migrate_from = [ + ('common', '0046_note'), + ('build', '0059_build_tags'), + ('company', '0079_auto_20260212_1054'), + ('order', '0119_transferorderlineitem_line_int'), + ('part', '0150_part_maximum_stock'), + ('stock', '0123_remove_stockitem_review_needed'), + ] + migrate_to = ('common', '0048_remove_notesimage_model_id_and_more') + + def generate_image(self, name: str): + """Generate a dummy image file for upload.""" + buf = io.BytesIO() + Image.new('RGB', (64, 64), color='red').save(buf, format='PNG') + return ContentFile(buf.getvalue(), name=name) + + def get_model(self, app: str, model: str): + """Fetch a historical model, working around the duplicate-column ORM bug. + + Historical models with a custom status field enumerate 'status_custom_key' twice + (once from contribute_to_class on the status field, once from the explicit + AddField migration), so ORM-generated INSERTs fail with + 'column specified more than once'. Remove the duplicated field entries here. + """ + model_class = self.old_state.apps.get_model(app, model) + + seen = set() + + for field in list(model_class._meta.local_fields): + if field.name in seen: + model_class._meta.local_fields.remove(field) + else: + seen.add(field.name) + + model_class._meta._expire_cache() + + return model_class + + def prepare(self): + """Create instances of each model type which supports notes.""" + # Dummy MPPT data + tree = {'tree_id': 0, 'level': 0, 'lft': 0, 'rght': 0} + + NotesImage = self.get_model('common', 'NotesImage') + + Part = self.get_model('part', 'Part') + Build = self.get_model('build', 'Build') + StockItem = self.get_model('stock', 'StockItem') + Company = self.get_model('company', 'Company') + ManufacturerPart = self.get_model('company', 'ManufacturerPart') + SupplierPart = self.get_model('company', 'SupplierPart') + PurchaseOrder = self.get_model('order', 'PurchaseOrder') + SalesOrder = self.get_model('order', 'SalesOrder') + ReturnOrder = self.get_model('order', 'ReturnOrder') + SalesOrderShipment = self.get_model('order', 'SalesOrderShipment') + TransferOrder = self.get_model('order', 'TransferOrder') + + # An image which is not linked to any model, but is embedded in the notes markdown + embedded_image = NotesImage.objects.create( + image=self.generate_image('embedded.png') + ) + + # An image which is not linked to any model, and not referenced anywhere + NotesImage.objects.create(image=self.generate_image('orphan.png')) + + part = Part.objects.create( + name='Test Part', + description='Test Part Description', + active=True, + assembly=True, + purchaseable=True, + notes=f'Some **bold** part notes\n\n![image]({embedded_image.image.url})', + **tree, + ) + + # Parts with empty notes should not generate a Note entry + Part.objects.create( + name='Part with empty notes', description='x', notes='', **tree + ) + Part.objects.create( + name='Part with null notes', description='x', notes=None, **tree + ) + + # An image which is directly linked to the part instance + NotesImage.objects.create( + image=self.generate_image('linked.png'), model_type='part', model_id=part.pk + ) + + company = Company.objects.create( + name='Test Company', + description='Test Company Description', + is_customer=True, + is_manufacturer=True, + is_supplier=True, + notes='Some **bold** company notes', + ) + + so = SalesOrder.objects.create( + reference='SO-12345', + customer=company, + description='Test Sales Order Description', + notes='Some **bold** sales order notes', + ) + + instances = [ + ('part', part), + ('company', company), + ('salesorder', so), + ( + 'manufacturerpart', + ManufacturerPart.objects.create( + part=part, + manufacturer=company, + MPN='MPN-123', + notes='Some **bold** manufacturer part notes', + ), + ), + ( + 'supplierpart', + SupplierPart.objects.create( + part=part, + supplier=company, + SKU='SKU-123', + notes='Some **bold** supplier part notes', + ), + ), + ( + 'build', + Build.objects.create( + part=part, + reference='BO-0001', + title='Test Build', + quantity=10, + notes='Some **bold** build notes', + **tree, + ), + ), + ( + 'stockitem', + StockItem.objects.create( + part=part, + quantity=10, + notes='Some **bold** stock item notes', + **tree, + ), + ), + ( + 'purchaseorder', + PurchaseOrder.objects.create( + reference='PO-12345', + supplier=company, + description='Test Purchase Order Description', + notes='Some **bold** purchase order notes', + ), + ), + ( + 'returnorder', + ReturnOrder.objects.create( + reference='RO-12345', + customer=company, + description='Test Return Order Description', + notes='Some **bold** return order notes', + ), + ), + ( + 'salesordershipment', + SalesOrderShipment.objects.create( + order=so, reference='SHIP-001', notes='Some **bold** shipment notes' + ), + ), + ( + 'transferorder', + TransferOrder.objects.create( + reference='TO-12345', + description='Test Transfer Order Description', + notes='Some **bold** transfer order notes', + ), + ), + ] + + # Record the expected (model_type, model_id) values for later comparison + self.expected_notes = [(model, instance.pk) for model, instance in instances] + self.part_pk = part.pk + + def test_notes_migrated(self): + """Test that a Note object has been created for each legacy notes field.""" + Note = self.new_state.apps.get_model('common', 'Note') + ContentType = self.new_state.apps.get_model('contenttypes', 'ContentType') + + # One note per instance with non-empty notes (and no extras) + self.assertEqual(Note.objects.count(), len(self.expected_notes)) + + for model, pk in self.expected_notes: + content_type = ContentType.objects.get(model=model) + note = Note.objects.get(model_type=content_type, model_id=pk) + + self.assertEqual(note.title, 'Note') + self.assertTrue(note.primary) + self.assertFalse(note.template) + + # Markdown content has been converted to HTML + self.assertIn('bold', note.content) + self.assertNotIn('**', note.content) + + def test_images_migrated(self): + """Test that NotesImage objects are correctly linked or removed.""" + Note = self.new_state.apps.get_model('common', 'Note') + ContentType = self.new_state.apps.get_model('contenttypes', 'ContentType') + NotesImage = self.new_state.apps.get_model('common', 'NotesImage') + + # The orphaned image has been removed + self.assertEqual(NotesImage.objects.count(), 2) + + content_type = ContentType.objects.get(model='part') + note = Note.objects.get(model_type=content_type, model_id=self.part_pk) + + # Both the directly linked image and the embedded image point to the part note + for image in NotesImage.objects.all(): + self.assertEqual(image.note.pk, note.pk) + + def prep_currency_migration(self, vals: str): """Prepare the environment for the currency migration tests.""" # Set keys