mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Import update (#10188)
* Add field to "update" existing records * Ensure the ID is first * Prevent editing of "ID" field * Extract db instance * Bump API version * Prevent edit of "id" field * Refactoring * Enhanced playwright tests for data importing * Update docs * Update src/backend/InvenTree/importer/models.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/frontend/src/forms/ImporterForms.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix silly AI mistake * Fix for table pagination - Ensure page does not exceed available records * Bug fix for playwright test * Add end-to-end API testing * Fix unit tests * Adjust table page logic * Ensure sensible page size * Simplify playwright test * Simplify test again * Tweak unit test - Importing has invalidated the BOM? * Adjust playwright tests * Further playwright fixes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
		| @@ -1,11 +1,14 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 386 | ||||
| INVENTREE_API_VERSION = 387 | ||||
|  | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
| v387 -> 2025-08-19 : https://github.com/inventree/InvenTree/pull/10188 | ||||
|     - Adds "update_records" field to the DataImportSession API | ||||
|  | ||||
| v386 -> 2025-08-11 : https://github.com/inventree/InvenTree/pull/8191 | ||||
|     - Adds "consumed" field to the BuildItem API | ||||
|     - Adds API endpoint to consume stock against a BuildOrder | ||||
|   | ||||
| @@ -0,0 +1,22 @@ | ||||
| # Generated by Django 4.2.23 on 2025-08-18 13:57 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("importer", "0004_alter_dataimportsession_model_type"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="dataimportsession", | ||||
|             name="update_records", | ||||
|             field=models.BooleanField( | ||||
|                 default=False, | ||||
|                 help_text="If enabled, existing records will be updated with new data", | ||||
|                 verbose_name="Update Existing Records", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,6 +1,7 @@ | ||||
| """Model definitions for the 'importer' app.""" | ||||
|  | ||||
| import json | ||||
| from collections import OrderedDict | ||||
| from typing import Optional | ||||
|  | ||||
| from django.contrib.auth.models import User | ||||
| @@ -39,6 +40,8 @@ class DataImportSession(models.Model): | ||||
|         field_filters: JSONField for field filter values - optional field API filters | ||||
|     """ | ||||
|  | ||||
|     ID_FIELD_LABEL = 'id' | ||||
|  | ||||
|     class ModelChoices(RenderChoices): | ||||
|         """Model choices for data import sessions.""" | ||||
|  | ||||
| @@ -118,6 +121,12 @@ class DataImportSession(models.Model): | ||||
|         validators=[importer.validators.validate_field_defaults], | ||||
|     ) | ||||
|  | ||||
|     update_records = models.BooleanField( | ||||
|         default=False, | ||||
|         verbose_name=_('Update Existing Records'), | ||||
|         help_text=_('If enabled, existing records will be updated with new data'), | ||||
|     ) | ||||
|  | ||||
|     @property | ||||
|     def field_mapping(self) -> dict: | ||||
|         """Construct a dict of field mappings for this import session. | ||||
| @@ -351,13 +360,25 @@ class DataImportSession(models.Model): | ||||
|  | ||||
|         metadata = InvenTreeMetadata() | ||||
|  | ||||
|         fields = OrderedDict() | ||||
|  | ||||
|         if self.update_records: | ||||
|             # If we are updating records, ensure the ID field is included | ||||
|             fields[self.ID_FIELD_LABEL] = { | ||||
|                 'label': _('ID'), | ||||
|                 'help_text': _('Existing database identifier for the record'), | ||||
|                 'type': 'integer', | ||||
|                 'required': True, | ||||
|                 'read_only': False, | ||||
|             } | ||||
|  | ||||
|         if serializer_class := self.serializer_class: | ||||
|             serializer = serializer_class(data={}, importing=True) | ||||
|             fields = metadata.get_serializer_info(serializer) | ||||
|         else: | ||||
|             fields = {} | ||||
|             fields.update(metadata.get_serializer_info(serializer)) | ||||
|  | ||||
|         # Cache the available fields against this instance | ||||
|         self._available_fields = fields | ||||
|  | ||||
|         return fields | ||||
|  | ||||
|     def required_fields(self) -> dict: | ||||
| @@ -370,6 +391,10 @@ class DataImportSession(models.Model): | ||||
|             if info.get('required', False): | ||||
|                 required[field] = info | ||||
|  | ||||
|             elif self.update_records and field == self.ID_FIELD_LABEL: | ||||
|                 # If we are updating records, the ID field is required | ||||
|                 required[field] = info | ||||
|  | ||||
|         return required | ||||
|  | ||||
|  | ||||
| @@ -630,11 +655,13 @@ class DataImportRow(models.Model): | ||||
|  | ||||
|         return data | ||||
|  | ||||
|     def construct_serializer(self, request=None): | ||||
|     def construct_serializer(self, instance=None, request=None): | ||||
|         """Construct a serializer object for this row.""" | ||||
|         if serializer_class := self.session.serializer_class: | ||||
|             return serializer_class( | ||||
|                 data=self.serializer_data(), context={'request': request} | ||||
|                 instance=instance, | ||||
|                 data=self.serializer_data(), | ||||
|                 context={'request': request}, | ||||
|             ) | ||||
|  | ||||
|     def validate(self, commit=False, request=None) -> bool: | ||||
| @@ -654,7 +681,26 @@ class DataImportRow(models.Model): | ||||
|             # Row has already been completed | ||||
|             return True | ||||
|  | ||||
|         serializer = self.construct_serializer(request=request) | ||||
|         if self.session.update_records: | ||||
|             # Extract the ID field from the data | ||||
|             instance_id = self.data.get(self.session.ID_FIELD_LABEL, None) | ||||
|  | ||||
|             if not instance_id: | ||||
|                 raise DjangoValidationError( | ||||
|                     _('ID is required for updating existing records.') | ||||
|                 ) | ||||
|  | ||||
|             try: | ||||
|                 instance = self.session.model_class.objects.get(pk=instance_id) | ||||
|             except self.session.model_class.DoesNotExist: | ||||
|                 raise DjangoValidationError(_('No record found with the provided ID.')) | ||||
|             except ValueError: | ||||
|                 raise DjangoValidationError(_('Invalid ID format provided.')) | ||||
|  | ||||
|             serializer = self.construct_serializer(instance=instance, request=request) | ||||
|  | ||||
|         else: | ||||
|             serializer = self.construct_serializer(request=request) | ||||
|  | ||||
|         if not serializer: | ||||
|             self.errors = { | ||||
|   | ||||
| @@ -41,6 +41,7 @@ class DataImportSessionSerializer(InvenTreeModelSerializer): | ||||
|             'pk', | ||||
|             'timestamp', | ||||
|             'data_file', | ||||
|             'update_records', | ||||
|             'model_type', | ||||
|             'available_fields', | ||||
|             'status', | ||||
|   | ||||
| @@ -0,0 +1,6 @@ | ||||
| ID,Name,Description,Default Location,Default keywords,Level,Parent Category,Parts,Subcategories,Path,Starred,Structural,Icon,Parent default location | ||||
| 23,Category 0,"Part category, level 1",,,0,,0,5,Category 0,False,False,, | ||||
| 1,Electronics,Electronic components and systems,,,0,,135,12,Electronics,False,False,, | ||||
| 17,Furniture,Furniture and associated things,,,0,,22,2,Furniture,False,False,, | ||||
| 2,Mechanical,Mechanical components,,,0,,263,3,Mechanical,False,False,, | ||||
| 20,Paint,"Paints, inks, etc",,,0,,5,0,Paint,False,False,, | ||||
| 
 | 
| @@ -3,25 +3,23 @@ | ||||
| import os | ||||
|  | ||||
| from django.core.files.base import ContentFile | ||||
| from django.urls import reverse | ||||
|  | ||||
| from importer.models import DataImportRow, DataImportSession | ||||
| from InvenTree.unit_test import AdminTestCase, InvenTreeTestCase | ||||
| from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase | ||||
|  | ||||
|  | ||||
| class ImporterMixin: | ||||
|     """Helpers for import tests.""" | ||||
|  | ||||
|     def helper_file(self): | ||||
|     def helper_file(self, fn: str) -> ContentFile: | ||||
|         """Return test data.""" | ||||
|         fn = os.path.join(os.path.dirname(__file__), 'test_data', 'companies.csv') | ||||
|         file_path = os.path.join(os.path.dirname(__file__), 'test_data', fn) | ||||
|  | ||||
|         with open(fn, encoding='utf-8') as input_file: | ||||
|         with open(file_path, encoding='utf-8') as input_file: | ||||
|             data = input_file.read() | ||||
|         return data | ||||
|  | ||||
|     def helper_content(self): | ||||
|         """Return content file.""" | ||||
|         return ContentFile(self.helper_file(), 'companies.csv') | ||||
|         return ContentFile(data, fn) | ||||
|  | ||||
|  | ||||
| class ImporterTest(ImporterMixin, InvenTreeTestCase): | ||||
| @@ -33,8 +31,10 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase): | ||||
|  | ||||
|         n = Company.objects.count() | ||||
|  | ||||
|         data_file = self.helper_file('companies.csv') | ||||
|  | ||||
|         session = DataImportSession.objects.create( | ||||
|             data_file=self.helper_content(), model_type='company' | ||||
|             data_file=data_file, model_type='company' | ||||
|         ) | ||||
|  | ||||
|         session.extract_columns() | ||||
| @@ -74,13 +74,116 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase): | ||||
|         """Test default field values.""" | ||||
|  | ||||
|  | ||||
| class ImportAPITest(ImporterMixin, InvenTreeAPITestCase): | ||||
|     """End-to-end tests for the importer API.""" | ||||
|  | ||||
|     def test_import(self): | ||||
|         """Test full import process via the API.""" | ||||
|         from part.models import PartCategory | ||||
|  | ||||
|         N = PartCategory.objects.count() | ||||
|  | ||||
|         url = reverse('api-importer-session-list') | ||||
|  | ||||
|         # Load data file | ||||
|         data_file = self.helper_file('part_categories.csv') | ||||
|  | ||||
|         data = self.post( | ||||
|             url, | ||||
|             {'model_type': 'partcategory', 'data_file': data_file}, | ||||
|             format='multipart', | ||||
|         ).data | ||||
|  | ||||
|         self.assertFalse(data['update_records']) | ||||
|         self.assertEqual(data['model_type'], 'partcategory') | ||||
|  | ||||
|         # No data has been imported yet | ||||
|         self.assertEqual(data['row_count'], 0) | ||||
|         self.assertEqual(data['completed_row_count'], 0) | ||||
|  | ||||
|         field_names = data['available_fields'].keys() | ||||
|  | ||||
|         for fn in ['name', 'default_location', 'description']: | ||||
|             self.assertIn(fn, field_names) | ||||
|  | ||||
|         self.assertEqual(len(data['columns']), 14) | ||||
|         for col in ['Name', 'Parent Category', 'Path']: | ||||
|             self.assertIn(col, data['columns']) | ||||
|  | ||||
|         session_id = data['pk'] | ||||
|  | ||||
|         # Accept the field mappings | ||||
|         url = reverse('api-import-session-accept-fields', kwargs={'pk': session_id}) | ||||
|  | ||||
|         # Initially the user does not have the right permissions | ||||
|         self.post(url, expected_code=403) | ||||
|  | ||||
|         # Assign correct permission to user | ||||
|         self.assignRole('part_category.add') | ||||
|  | ||||
|         self.post(url, expected_code=200) | ||||
|  | ||||
|         session = self.get( | ||||
|             reverse('api-import-session-detail', kwargs={'pk': session_id}) | ||||
|         ).data | ||||
|  | ||||
|         self.assertEqual(session['row_count'], 5) | ||||
|         self.assertEqual(session['completed_row_count'], 0) | ||||
|  | ||||
|         # Fetch each row, and validate it | ||||
|         rows = self.get( | ||||
|             reverse('api-importer-row-list'), data={'session': session_id} | ||||
|         ).data | ||||
|  | ||||
|         self.assertEqual(len(rows), 5) | ||||
|  | ||||
|         row_ids = [] | ||||
|  | ||||
|         for row in rows: | ||||
|             row_ids.append(row['pk']) | ||||
|             self.assertEqual(row['session'], session_id) | ||||
|             self.assertTrue(row['valid']) | ||||
|             self.assertFalse(row['complete']) | ||||
|  | ||||
|         # Validate the rows | ||||
|         url = reverse('api-import-session-accept-rows', kwargs={'pk': session_id}) | ||||
|  | ||||
|         self.post( | ||||
|             url, | ||||
|             { | ||||
|                 'rows': row_ids[1:]  # Validate all but the first row | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|         # Update session information | ||||
|         session = self.get( | ||||
|             reverse('api-import-session-detail', kwargs={'pk': session_id}) | ||||
|         ).data | ||||
|  | ||||
|         self.assertEqual(session['row_count'], 5) | ||||
|         self.assertEqual(session['completed_row_count'], 4) | ||||
|  | ||||
|         for idx, row in enumerate(row_ids): | ||||
|             detail = self.get( | ||||
|                 reverse('api-importer-row-detail', kwargs={'pk': row}) | ||||
|             ).data | ||||
|  | ||||
|             self.assertEqual(detail['session'], session_id) | ||||
|             self.assertEqual(detail['complete'], idx > 0) | ||||
|  | ||||
|         # Check that there are new database records | ||||
|         self.assertEqual(PartCategory.objects.count(), N + 4) | ||||
|  | ||||
|  | ||||
| class AdminTest(ImporterMixin, AdminTestCase): | ||||
|     """Tests for the admin interface integration.""" | ||||
|  | ||||
|     def test_admin(self): | ||||
|         """Test the admin URL.""" | ||||
|         data_file = self.helper_file('companies.csv') | ||||
|  | ||||
|         session = self.helper( | ||||
|             model=DataImportSession, | ||||
|             model_kwargs={'data_file': self.helper_content(), 'model_type': 'company'}, | ||||
|             model_kwargs={'data_file': data_file, 'model_type': 'company'}, | ||||
|         ) | ||||
|         self.helper(model=DataImportRow, model_kwargs={'session_id': session.id}) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user