diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 0952eb4cd4..7b685369a3 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -148,7 +148,7 @@ jobs: - name: Export API Documentation run: invoke dev.schema --ignore-warnings --filename src/backend/InvenTree/schema.yml - name: Upload schema - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # pin@v4.4.3 + uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # pin@v4.5.0 with: name: schema.yml path: src/backend/InvenTree/schema.yml @@ -209,7 +209,7 @@ jobs: with: repository: inventree/schema token: ${{ secrets.SCHEMA_PAT }} - persist-credentials: false + persist-credentials: true - name: Download schema artifact uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # pin@v4.1.8 with: @@ -305,7 +305,7 @@ jobs: - name: Coverage Tests run: invoke dev.test --coverage - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@7f8b4b4bde536c465e797be725718b88c5d95e0e # pin@v5.1.1 + uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # pin@v5.1.2 if: always() with: token: ${{ secrets.CODECOV_TOKEN }} @@ -443,7 +443,7 @@ jobs: - name: Run Tests run: invoke dev.test --migrations --report --coverage - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@7f8b4b4bde536c465e797be725718b88c5d95e0e # pin@v5.1.1 + uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # pin@v5.1.2 if: always() with: token: ${{ secrets.CODECOV_TOKEN }} @@ -542,7 +542,7 @@ jobs: - name: Run Playwright tests id: tests run: cd src/frontend && npx nyc playwright test - - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # pin@v4.4.3 + - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # pin@v4.5.0 if: ${{ !cancelled() && steps.tests.outcome == 'failure' }} with: name: playwright-report @@ -552,7 +552,7 @@ jobs: if: always() run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@7f8b4b4bde536c465e797be725718b88c5d95e0e # pin@v5.1.1 + uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # pin@v5.1.2 if: always() with: token: ${{ secrets.CODECOV_TOKEN }} @@ -589,7 +589,7 @@ jobs: run: | cd src/backend/InvenTree/web/static zip -r frontend-build.zip web/ web/.vite - - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # pin@v4.4.3 + - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # pin@v4.5.0 with: name: frontend-build path: src/backend/InvenTree/web/static/web @@ -614,7 +614,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@df409f7d9260372bd5f19e5b04e83cb3c43714ae # pin@v3 + uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # pin@v3 with: sarif_file: results.sarif category: zizmor diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml index 13255fddea..c15d99301a 100644 --- a/.github/workflows/scorecard.yaml +++ b/.github/workflows/scorecard.yaml @@ -59,7 +59,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 with: name: SARIF file path: results.sarif @@ -67,6 +67,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3.27.9 + uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 with: sarif_file: results.sarif diff --git a/docs/docs/assets/images/admin/export.png b/docs/docs/assets/images/admin/export.png index 1180684c11..e8c30e33f5 100644 Binary files a/docs/docs/assets/images/admin/export.png and b/docs/docs/assets/images/admin/export.png differ diff --git a/docs/docs/assets/images/admin/import.png b/docs/docs/assets/images/admin/import.png deleted file mode 100644 index 74901c3b25..0000000000 Binary files a/docs/docs/assets/images/admin/import.png and /dev/null differ diff --git a/docs/docs/assets/images/admin/import_error.png b/docs/docs/assets/images/admin/import_error.png deleted file mode 100644 index 8c83a53027..0000000000 Binary files a/docs/docs/assets/images/admin/import_error.png and /dev/null differ diff --git a/docs/docs/assets/images/admin/import_preview.png b/docs/docs/assets/images/admin/import_preview.png deleted file mode 100644 index dfa7b6cf43..0000000000 Binary files a/docs/docs/assets/images/admin/import_preview.png and /dev/null differ diff --git a/docs/docs/assets/images/admin/import_session_create.png b/docs/docs/assets/images/admin/import_session_create.png new file mode 100644 index 0000000000..4420b269fb Binary files /dev/null and b/docs/docs/assets/images/admin/import_session_create.png differ diff --git a/docs/docs/assets/images/admin/import_session_map.png b/docs/docs/assets/images/admin/import_session_map.png new file mode 100644 index 0000000000..ff1dd10e5c Binary files /dev/null and b/docs/docs/assets/images/admin/import_session_map.png differ diff --git a/docs/docs/assets/images/admin/import_session_process.png b/docs/docs/assets/images/admin/import_session_process.png new file mode 100644 index 0000000000..f385401835 Binary files /dev/null and b/docs/docs/assets/images/admin/import_session_process.png differ diff --git a/docs/docs/assets/images/admin/import_upload.png b/docs/docs/assets/images/admin/import_upload.png deleted file mode 100644 index 1f93b67f73..0000000000 Binary files a/docs/docs/assets/images/admin/import_upload.png and /dev/null differ diff --git a/docs/docs/settings/export.md b/docs/docs/settings/export.md index e9200c662c..313999556b 100644 --- a/docs/docs/settings/export.md +++ b/docs/docs/settings/export.md @@ -4,14 +4,10 @@ title: Exporting Data ## Exporting Data -The Admin Interface provides powerful data exporting capability. When displaying a list of items which support exporting (e.g. Part objects), select the "Export" button from the top-right corner: +InvenTree provides data export functionality for a variety of data types. Most data tables provide an "Export Data" button, which allows the user to export the data in a variety of formats. -{% with id="export", url="admin/export.png", description="Data export" %} -{% include 'img.html' %} -{% endwith %} - -Multiple data formats are supported for exported data: - -{% with id="formats", url="admin/formats.png", description="Data formats" %} +In the top right corner of the table, click the "Export Data" button to export the data in the table. + +{% with id="export", url="admin/export.png", description="Export data" %} {% include 'img.html' %} {% endwith %} diff --git a/docs/docs/settings/import.md b/docs/docs/settings/import.md index 07d89acce2..ed90511cf8 100644 --- a/docs/docs/settings/import.md +++ b/docs/docs/settings/import.md @@ -15,77 +15,70 @@ External data can be imported via the admin interface, allowing for rapid integr !!! warning "Supported Models" Not all models in the InvenTree database support bulk import actions. -When viewing a model (which supports bulk data import) in the admin interface, select the "Import" button in the top-right corner: +### Required Permissions -{% with id="import", url="admin/import.png", description="Data import" %} +To import data, the user must have the appropriate permissions. The user must be a *staff* user, and have the `change` permission for the model in question. + +## Import Session + +Importing data is a multi-step process, which is managed via an *import session*. An import session is created when the user initiates a data import, and is used to track the progress of the data import process. + +### Import Session List + +The import session is managed by the InvenTree server, and all import session data is stored on the server. As the import process can be time-consuming, the user can navigate away from the import page and return later to check on the progress of the import. + +Import sessions can be managed from the *Admin Center* page, which lists all available import sessions + +### Context Sensitive Importing + +Depending on the type of data being imported, an import session can be created from an appropriate page context in the user interface. In such cases, the import session will be automatically linked to the relevant data type being imported. + +## Import Process + +The following steps outline the process of importing data into InvenTree: + +### Create Import Session + +An import session can be created via the methods outlined above. The first step is to create an import session, and upload the data file to import. Note that depending on the context of the data import, the user may have to select the database model to import data into. + +{% with id="import-create", url="admin/import_session_create.png", description="Create import session" %} {% include 'img.html' %} {% endwith %} -The next screen displays a list of column headings which are expected to be present in the uploaded data file. +### Map Data Fields -{% with id="import_upload", url="admin/import_upload.png", description="Data upload" %} +Next, the user must map the data fields in the uploaded file to the fields in the database model. This is a critical step, as the data fields must be correctly matched to the database fields. + +{% with id="import-map", url="admin/import_session_map.png", description="Map data fields" %} {% include 'img.html' %} {% endwith %} -Select the data file to import, and the data format. Press the "Submit" button to upload the file. +The InvenTree server will attempt to automatically associate the data fields in the uploaded file with the database fields. However, the user may need to manually adjust the field mappings to ensure that the data is imported correctly. -### File Format +### Import Data -The uploaded data file must meet a number of formatting requirements for successful data upload. A simple way of ensuring that the file format is correct is to first [export data](./export.md) for the model in question, and delete all data rows (not the header row) from the exported data file. +Once the data fields have been mapped, the data is loaded from the file, and stored (temporarily) in the import session. This step is performed automatically by the InvenTree server once the user has confirmed the field mappings. -Then, the same file can be used as a template for uploading more data to the server. +Note that this process may take some time if the data file is large. The import process is handled by the background worker process, and the user can navigate away from the import page and return later to check on the progress of the import. -### ID Field +### Process Data -The uploaded data file requires a special field called `id`. This `id` field uniquely identifies each entry in the database table(s) - it is also known as a *primary key*. +Once the data has been loaded into the import session, the user can process the data. This step will attempt to validate the data, and check for any errors or issues that may prevent the data from being imported. -The `id` column **must** be present in an uploaded data file, as it is required to know how to process the incoming data. - -Depending on the value of the `id` field in each row, InvenTree will attempt to either insert a new record into the database, or update an existing one. - -#### Empty ID - -If the `id` field in a given data row is empty (blank), then InvenTree interprets that particular row as a *new* entry which will be inserted into the database. - -If you wish for a new database entry to be created for a particular data row, the `id` field **must** be left blank for that row. - -#### Non-Empty ID - -If the `id` field in a given data row is *not* empty, then InvenTree interprets that particular row as an *existing* row to override / update. - -In this case, InvenTree will search the database for an entry with the matching `id`. If a matching entry is found, then the entry is updated with the provided data. - -However, if an entry is *not* found with the matching `id`, InvenTree will return an error message, as it cannot find the matching database entry to update. - -!!! warning "Check id Value" - Exercise caution when uploading data with the `id` field specified! - -### Import Preview - -After the data file has been uploaded and validated, the user is presented with a *preview* screen, showing the records that will be inserted or updated in the database. - -Here the user has a final chance to review the data upload. - -Press the *Confirm Import* button to actually perform the import process and commit the data into the database. - -{% with id="import_preview", url="admin/import_preview.png", description="Data upload preview" %} +{% with id="import-process", url="admin/import_session_process.png", description="Process data" %} {% include 'img.html' %} {% endwith %} -Note that *new* records are automatically assigned an `id` value. +Note that each row must be selected and confirmed by the user before it is actually imported into the database. Any errors which are detected will be displayed to the user, and the user can choose to correct the data and re-process it. -## Import Errors +During the processing step, the status of each row is displayed at the left of the table. Each row can be in one of the following states: -Manually importing data in a relational database is a complex process. You may be presented with an error message which describes why the data could not be imported. +- **Error**: The row contains an error which must be corrected before it can be imported. +- **Pending**: The row contains no errors, and is ready to be imported. +- **Imported**: The row has been successfully imported into the database. -The error message should contain enough information to manually edit the data file to fix the problem. +Each individual row can be imported, or removed (deleted) by the user. Once all the rows have been processed, the import session is considered *complete*. -Any error messages are displayed per row, and you can hover the mouse over the particular error message to view specific error details: +### Import Completed -{% with id="import_error", url="admin/import_error.png", description="Data upload error" %} -{% include 'img.html' %} -{% endwith %} - - -!!! info "Report Issue" - If the error message does not provide enough information, or the error seems like a bug caused by InvenTree itself, report an [issue on Github](https://github.com/inventree/inventree/issues). +Once all records have been processed, the import session is considered complete. The import session can be closed, and the imported records are now stored in the database. diff --git a/pyproject.toml b/pyproject.toml index fc10ce92ed..6f37c4efaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,6 @@ exclude = [ ".git", "__pycache__", - "dist", - "build", "test.py", "tests", "venv", diff --git a/src/backend/InvenTree/InvenTree/admin.py b/src/backend/InvenTree/InvenTree/admin.py index 66346e9eef..2655794159 100644 --- a/src/backend/InvenTree/InvenTree/admin.py +++ b/src/backend/InvenTree/InvenTree/admin.py @@ -1,132 +1,10 @@ """Admin classes.""" from django.contrib import admin -from django.db.models.fields import CharField from django.http.request import HttpRequest from djmoney.contrib.exchange.admin import RateAdmin from djmoney.contrib.exchange.models import Rate -from import_export.exceptions import ImportExportError -from import_export.resources import ModelResource - - -class InvenTreeResource(ModelResource): - """Custom subclass of the ModelResource class provided by django-import-export". - - Ensures that exported data are escaped to prevent malicious formula injection. - Ref: https://owasp.org/www-community/attacks/CSV_Injection - """ - - MAX_IMPORT_ROWS = 1000 - MAX_IMPORT_COLS = 100 - - # List of fields which should be converted to empty strings if they are null - CONVERT_NULL_FIELDS = [] - - def import_data_inner( - self, - dataset, - dry_run, - raise_errors, - using_transactions, - collect_failed_rows, - rollback_on_validation_errors=None, - **kwargs, - ): - """Override the default import_data_inner function to provide better error handling.""" - if len(dataset) > self.MAX_IMPORT_ROWS: - raise ImportExportError( - f'Dataset contains too many rows (max {self.MAX_IMPORT_ROWS})' - ) - - if len(dataset.headers) > self.MAX_IMPORT_COLS: - raise ImportExportError( - f'Dataset contains too many columns (max {self.MAX_IMPORT_COLS})' - ) - - return super().import_data_inner( - dataset, - dry_run, - raise_errors, - using_transactions, - collect_failed_rows, - rollback_on_validation_errors=rollback_on_validation_errors, - **kwargs, - ) - - def export_resource(self, obj): - """Custom function to override default row export behavior. - - Specifically, strip illegal leading characters to prevent formula injection - """ - row = super().export_resource(obj) - - illegal_start_vals = ['@', '=', '+', '-', '@', '\t', '\r', '\n'] - - for idx, val in enumerate(row): - if type(val) is str: - val = val.strip() - - # If the value starts with certain 'suspicious' values, remove it! - while len(val) > 0 and val[0] in illegal_start_vals: - # Remove the first character - val = val[1:] - - row[idx] = val - - return row - - def get_fields(self, **kwargs): - """Return fields, with some common exclusions.""" - fields = super().get_fields(**kwargs) - - fields_to_exclude = ['metadata', 'lft', 'rght', 'tree_id', 'level'] - - return [f for f in fields if f.column_name not in fields_to_exclude] - - def before_import(self, dataset, using_transactions, dry_run, **kwargs): - """Run custom code before importing data. - - - Determine the list of fields which need to be converted to empty strings - """ - # Construct a map of field names - db_fields = {field.name: field for field in self.Meta.model._meta.fields} - - for field_name, field in self.fields.items(): - # Skip read-only fields (they cannot be imported) - if field.readonly: - continue - - # Determine the name of the associated column in the dataset - column = getattr(field, 'column_name', field_name) - - # Determine the attribute name of the associated database field - attribute = getattr(field, 'attribute', field_name) - - # Check if the associated database field is a non-nullable string - if ( - (db_field := db_fields.get(attribute)) - and ( - isinstance(db_field, CharField) - and db_field.blank - and not db_field.null - ) - and column not in self.CONVERT_NULL_FIELDS - ): - self.CONVERT_NULL_FIELDS.append(column) - - return super().before_import(dataset, using_transactions, dry_run, **kwargs) - - def before_import_row(self, row, row_number=None, **kwargs): - """Run custom code before importing each row. - - - Convert any null fields to empty strings, for fields which do not support null values - """ - for field in self.CONVERT_NULL_FIELDS: - if field in row and row[field] is None: - row[field] = '' - - return super().before_import_row(row, row_number, **kwargs) class CustomRateAdmin(RateAdmin): diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 29f8d2eaa9..4b858002f1 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,16 +1,22 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 294 +INVENTREE_API_VERSION = 296 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ -v294 - 2024-12-22 : https://github.com/inventree/InvenTree/pull/6293 +v296 - 2024-12-22 : https://github.com/inventree/InvenTree/pull/6293 - Removes a considerable amount of old auth endpoints - Introduces allauth based REST API +v295 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8746 + - Improve API documentation for build APIs + +v294 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8738 + - Extends registration API documentation + v293 - 2024-12-14 : https://github.com/inventree/InvenTree/pull/8658 - Adds new fields to the supplier barcode API endpoints diff --git a/src/backend/InvenTree/InvenTree/auth_override_views.py b/src/backend/InvenTree/InvenTree/auth_override_views.py new file mode 100644 index 0000000000..3362c3bf07 --- /dev/null +++ b/src/backend/InvenTree/InvenTree/auth_override_views.py @@ -0,0 +1,40 @@ +"""Overrides for registration view.""" + +from django.utils.translation import gettext_lazy as _ + +from allauth.account import app_settings as allauth_account_settings +from dj_rest_auth.app_settings import api_settings +from dj_rest_auth.registration.views import RegisterView + + +class CustomRegisterView(RegisterView): + """Registers a new user. + + Accepts the following POST parameters: username, email, password1, password2. + """ + + # Fixes https://github.com/inventree/InvenTree/issues/8707 + # This contains code from dj-rest-auth 7.0 - therefore the version was pinned + def get_response_data(self, user): + """Override to fix check for auth_model.""" + if ( + allauth_account_settings.EMAIL_VERIFICATION + == allauth_account_settings.EmailVerificationMethod.MANDATORY + ): + return {'detail': _('Verification e-mail sent.')} + + if api_settings.USE_JWT: + data = { + 'user': user, + 'access': self.access_token, + 'refresh': self.refresh_token, + } + return api_settings.JWT_SERIALIZER( + data, context=self.get_serializer_context() + ).data + elif self.token_model: + # Only change in this block is below + return api_settings.TOKEN_SERIALIZER( + user.api_tokens.last(), context=self.get_serializer_context() + ).data + return None diff --git a/src/backend/InvenTree/InvenTree/auth_overrides.py b/src/backend/InvenTree/InvenTree/auth_overrides.py index 3a5c515d0c..11e33d539e 100644 --- a/src/backend/InvenTree/InvenTree/auth_overrides.py +++ b/src/backend/InvenTree/InvenTree/auth_overrides.py @@ -98,7 +98,7 @@ def registration_enabled(): return False -class RegistratonMixin: +class RegistrationMixin: """Mixin to check if registration should be enabled.""" def is_open_for_signup(self, request, *args, **kwargs): @@ -168,7 +168,7 @@ class CustomUrlMixin: return InvenTree.helpers_model.construct_absolute_url(url) -class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultAccountAdapter): +class CustomAccountAdapter(CustomUrlMixin, RegistrationMixin, DefaultAccountAdapter): """Override of adapter to use dynamic settings.""" def send_mail(self, template_prefix, email, context): @@ -194,7 +194,7 @@ class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultAccountAdapt class CustomSocialAccountAdapter( - CustomUrlMixin, RegistratonMixin, DefaultSocialAccountAdapter + CustomUrlMixin, RegistrationMixin, DefaultSocialAccountAdapter ): """Override of adapter to use dynamic settings.""" diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 12e3c06b96..8870d9cf11 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -283,7 +283,6 @@ INSTALLED_APPS = [ 'django_filters', # Extended filter functionality 'rest_framework', # DRF (Django Rest Framework) 'corsheaders', # Cross-origin Resource Sharing for DRF - 'import_export', # Import / export tables to file 'django_cleanup.apps.CleanupConfig', # Automatically delete orphaned MEDIA files 'mptt', # Modified Preorder Tree Traversal 'markdownify', # Markdown template rendering @@ -1018,9 +1017,6 @@ USE_TZ = bool(not TESTING) DATE_INPUT_FORMATS = ['%Y-%m-%d'] -# Use database transactions when importing / exporting data -IMPORT_EXPORT_USE_TRANSACTIONS = True - # Site URL can be specified statically, or via a run-time setting SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None) diff --git a/src/backend/InvenTree/InvenTree/test_sso.py b/src/backend/InvenTree/InvenTree/test_auth.py similarity index 62% rename from src/backend/InvenTree/InvenTree/test_sso.py rename to src/backend/InvenTree/InvenTree/test_auth.py index 31481bc09d..10aa5c3e09 100644 --- a/src/backend/InvenTree/InvenTree/test_sso.py +++ b/src/backend/InvenTree/InvenTree/test_auth.py @@ -1,6 +1,8 @@ -"""Test the sso module functionality.""" +"""Test the sso and auth module functionality.""" +from django.conf import settings from django.contrib.auth.models import Group, User +from django.core.exceptions import ValidationError from django.test import override_settings from django.test.testcases import TransactionTestCase @@ -8,18 +10,19 @@ from allauth.socialaccount.models import SocialAccount, SocialLogin from common.models import InvenTreeSetting from InvenTree import sso -from InvenTree.auth_overrides import RegistratonMixin +from InvenTree.auth_overrides import RegistrationMixin +from InvenTree.unit_test import InvenTreeAPITestCase class Dummy: - """Simulate super class of RegistratonMixin.""" + """Simulate super class of RegistrationMixin.""" def save_user(self, _request, user: User, *args) -> User: """This method is only used that the super() call of RegistrationMixin does not fail.""" return user -class MockRegistrationMixin(RegistratonMixin, Dummy): +class MockRegistrationMixin(RegistrationMixin, Dummy): """Mocked implementation of the RegistrationMixin.""" @@ -119,3 +122,90 @@ class TestSsoGroupSync(TransactionTestCase): self.assertEqual(Group.objects.filter(name='inventree_group').count(), 0) sso.ensure_sso_groups(None, self.sociallogin) self.assertEqual(Group.objects.filter(name='inventree_group').count(), 1) + + +class EmailSettingsContext: + """Context manager to enable email settings for tests.""" + + def __enter__(self): + """Enable stuff.""" + InvenTreeSetting.set_setting('LOGIN_ENABLE_REG', True) + settings.EMAIL_HOST = 'localhost' + + def __exit__(self, type, value, traceback): + """Exit stuff.""" + InvenTreeSetting.set_setting('LOGIN_ENABLE_REG', False) + settings.EMAIL_HOST = '' + + +class TestAuth(InvenTreeAPITestCase): + """Test authentication functionality.""" + + def email_args(self, user=None, email=None): + """Generate registration arguments.""" + return { + 'username': user or 'user1', + 'email': email or 'test@example.com', + 'password1': '#asdf1234', + 'password2': '#asdf1234', + } + + def test_registration(self): + """Test the registration process.""" + self.logout() + + # Duplicate username + resp = self.post( + '/api/auth/registration/', + self.email_args(user='testuser'), + expected_code=400, + ) + self.assertIn( + 'A user with that username already exists.', resp.data['username'] + ) + + # Registration is disabled + resp = self.post( + '/api/auth/registration/', self.email_args(), expected_code=400 + ) + self.assertIn('Registration is disabled.', resp.data['non_field_errors']) + + # Enable registration - now it should work + with EmailSettingsContext(): + resp = self.post( + '/api/auth/registration/', self.email_args(), expected_code=201 + ) + self.assertIn('key', resp.data) + + def test_registration_email(self): + """Test that LOGIN_SIGNUP_MAIL_RESTRICTION works.""" + self.logout() + + # Check the setting validation is working + with self.assertRaises(ValidationError): + InvenTreeSetting.set_setting( + 'LOGIN_SIGNUP_MAIL_RESTRICTION', 'example.com,inventree.org' + ) + + # Setting setting correctly + correct_setting = '@example.com,@inventree.org' + InvenTreeSetting.set_setting('LOGIN_SIGNUP_MAIL_RESTRICTION', correct_setting) + self.assertEqual( + InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_RESTRICTION'), + correct_setting, + ) + + # Wrong email format + resp = self.post( + '/api/auth/registration/', + self.email_args(email='admin@invenhost.com'), + expected_code=400, + ) + self.assertIn('The provided email domain is not approved.', resp.data['email']) + + # Right format should work + with EmailSettingsContext(): + resp = self.post( + '/api/auth/registration/', self.email_args(), expected_code=201 + ) + self.assertIn('key', resp.data) diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index 5ed177be40..91be4f865a 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -32,7 +32,7 @@ from common.currency import currency_codes from common.models import CustomUnit, InvenTreeSetting from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin from InvenTree.sanitizer import sanitize_svg -from InvenTree.unit_test import InvenTreeTestCase +from InvenTree.unit_test import InvenTreeTestCase, in_env_context from part.models import Part, PartCategory from stock.models import StockItem, StockLocation @@ -1121,10 +1121,6 @@ class TestSettings(InvenTreeTestCase): superuser = True - def in_env_context(self, envs): - """Patch the env to include the given dict.""" - return mock.patch.dict(os.environ, envs) - def run_reload(self, envs=None): """Helper function to reload InvenTree.""" # Set default - see B006 @@ -1133,7 +1129,7 @@ class TestSettings(InvenTreeTestCase): from plugin import registry - with self.in_env_context(envs): + with in_env_context(envs): settings.USER_ADDED = False registry.reload_plugins() @@ -1198,7 +1194,7 @@ class TestSettings(InvenTreeTestCase): ) # with env set - with self.in_env_context({ + with in_env_context({ 'INVENTREE_CONFIG_FILE': '_testfolder/my_special_conf.yaml' }): self.assertIn( @@ -1217,7 +1213,7 @@ class TestSettings(InvenTreeTestCase): ) # with env set - with self.in_env_context({ + with in_env_context({ 'INVENTREE_PLUGIN_FILE': '_testfolder/my_special_plugins.txt' }): self.assertIn( @@ -1231,7 +1227,7 @@ class TestSettings(InvenTreeTestCase): self.assertEqual(config.get_setting(TEST_ENV_NAME, None, '123!'), '123!') # with env set - with self.in_env_context({TEST_ENV_NAME: '321'}): + with in_env_context({TEST_ENV_NAME: '321'}): self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321') # test typecasting to dict - None should be mapped to empty dict @@ -1240,13 +1236,13 @@ class TestSettings(InvenTreeTestCase): ) # test typecasting to dict - valid JSON string should be mapped to corresponding dict - with self.in_env_context({TEST_ENV_NAME: '{"a": 1}'}): + with in_env_context({TEST_ENV_NAME: '{"a": 1}'}): self.assertEqual( config.get_setting(TEST_ENV_NAME, None, typecast=dict), {'a': 1} ) # test typecasting to dict - invalid JSON string should be mapped to empty dict - with self.in_env_context({TEST_ENV_NAME: "{'a': 1}"}): + with in_env_context({TEST_ENV_NAME: "{'a': 1}"}): self.assertEqual(config.get_setting(TEST_ENV_NAME, None, typecast=dict), {}) diff --git a/src/backend/InvenTree/InvenTree/unit_test.py b/src/backend/InvenTree/InvenTree/unit_test.py index 6bc758b71d..6511c384fa 100644 --- a/src/backend/InvenTree/InvenTree/unit_test.py +++ b/src/backend/InvenTree/InvenTree/unit_test.py @@ -3,10 +3,12 @@ import csv import io import json +import os import re import time from contextlib import contextmanager from pathlib import Path +from unittest import mock from django.contrib.auth import get_user_model from django.contrib.auth.models import Group, Permission @@ -601,3 +603,8 @@ class AdminTestCase(InvenTreeAPITestCase): self.assertEqual(response.status_code, 200) return obj + + +def in_env_context(envs): + """Patch the env to include the given dict.""" + return mock.patch.dict(os.environ, envs) diff --git a/src/backend/InvenTree/InvenTree/views.py b/src/backend/InvenTree/InvenTree/views.py index a20836fc9f..4dee011266 100644 --- a/src/backend/InvenTree/InvenTree/views.py +++ b/src/backend/InvenTree/InvenTree/views.py @@ -4,15 +4,7 @@ In particular these views provide base functionality for rendering Django forms as JSON objects and passing them to modal forms (using jQuery / bootstrap). """ -from django.contrib.auth.mixins import PermissionRequiredMixin -from django.http import HttpResponse, HttpResponseRedirect, JsonResponse -from django.template.loader import render_to_string -from django.views import View -from django.views.generic import DeleteView, DetailView, ListView, UpdateView - -from users.models import RuleSet, check_user_role - -from .helpers import is_ajax +from django.http import HttpResponse def auth_request(request): @@ -20,354 +12,6 @@ def auth_request(request): Useful for (for example) redirecting authentication requests through django's permission framework. """ - if request.user.is_authenticated: + if request.user and request.user.is_authenticated: return HttpResponse(status=200) return HttpResponse(status=403) - - -class InvenTreeRoleMixin(PermissionRequiredMixin): - """Permission class based on user roles, not user 'permissions'. - - There are a number of ways that the permissions can be specified for a view: - - 1. Specify the 'role_required' attribute (e.g. part.change) - 2. Specify the 'permission_required' attribute (e.g. part.change_bomitem) - (Note: This is the "normal" django-esque way of doing this) - 3. Do nothing. The mixin will attempt to "guess" what permission you require: - a) If there is a queryset associated with the View, we have the model! - b) The *type* of View tells us the permission level (e.g. AjaxUpdateView = change) - c) 1 + 1 = 3 - d) Use the combination of model + permission as we would in 2) - - 1. Specify the 'role_required' attribute - ===================================== - To specify which role is required for the mixin, - set the class attribute 'role_required' to something like the following: - - role_required = 'part.add' - role_required = [ - 'part.change', - 'build.add', - ] - - 2. Specify the 'permission_required' attribute - =========================================== - To specify a particular low-level permission, - set the class attribute 'permission_required' to something like: - - permission_required = 'company.delete_company' - - 3. Do Nothing - ========== - - See above. - """ - - # By default, no roles are required - # Roles must be specified - role_required = None - - def has_permission(self): - """Determine if the current user has specified permissions.""" - roles_required = [] - - if type(self.role_required) is str: - roles_required.append(self.role_required) - elif type(self.role_required) in [list, tuple]: - roles_required = self.role_required - - user = self.request.user - - # Superuser can have any permissions they desire - if user.is_superuser: - return True - - for required in roles_required: - (role, permission) = required.split('.') - - if role not in RuleSet.RULESET_NAMES: - raise ValueError(f"Role '{role}' is not a valid role") - - if permission not in RuleSet.RULESET_PERMISSIONS: - raise ValueError(f"Permission '{permission}' is not a valid permission") - - # Return False if the user does not have *any* of the required roles - if not check_user_role(user, role, permission): - return False - - # If a permission_required is specified, use that! - if self.permission_required: - # Ignore role-based permissions - return super().has_permission() - - # Ok, so at this point we have not explicitly require a "role" or a "permission" - # Instead, we will use the model to introspect the data we need - - model = getattr(self, 'model', None) - - if not model: - queryset = getattr(self, 'queryset', None) - - if queryset is not None: - model = queryset.model - - # We were able to introspect a database model - if model is not None: - app_label = model._meta.app_label - model_name = model._meta.model_name - - table = f'{app_label}_{model_name}' - - permission = self.get_permission_class() - - if not permission: - raise AttributeError( - f'permission_class not defined for {type(self).__name__}' - ) - - # Check if the user has the required permission - return RuleSet.check_table_permission(user, table, permission) - - # We did not fail any required checks - return True - - def get_permission_class(self): - """Return the 'permission_class' required for the current View. - - Must be one of: - - - view - - change - - add - - delete - - This can either be explicitly defined, by setting the - 'permission_class' attribute, - or it can be "guessed" by looking at the type of class - """ - perm = getattr(self, 'permission_class', None) - - # Permission is specified by the class itself - if perm: - return perm - - # Otherwise, we will need to have a go at guessing... - permission_map = { - AjaxView: 'view', - ListView: 'view', - DetailView: 'view', - UpdateView: 'change', - DeleteView: 'delete', - AjaxUpdateView: 'change', - } - - for view_class in permission_map: - if issubclass(type(self), view_class): - return permission_map[view_class] - - return None - - -class AjaxMixin(InvenTreeRoleMixin): - """AjaxMixin provides basic functionality for rendering a Django form to JSON. Handles jsonResponse rendering, and adds extra data for the modal forms to process on the client side. - - Any view which inherits the AjaxMixin will need - correct permissions set using the 'role_required' attribute - """ - - # By default, allow *any* role - role_required = None - - # By default, point to the modal_form template - # (this can be overridden by a child class) - ajax_template_name = 'modal_form.html' - - ajax_form_title = '' - - def get_form_title(self): - """Default implementation - return the ajax_form_title variable.""" - return self.ajax_form_title - - def get_param(self, name, method='GET'): - """Get a request query parameter value from URL e.g. ?part=3. - - Args: - name: Variable name e.g. 'part' - method: Request type ('GET' or 'POST') - - Returns: - Value of the supplier parameter or None if parameter is not available - """ - if method == 'POST': - return self.request.POST.get(name, None) - return self.request.GET.get(name, None) - - def get_data(self): - """Get extra context data (default implementation is empty dict). - - Returns: - dict object (empty) - """ - return {} - - def validate(self, obj, form, **kwargs): - """Hook for performing custom form validation steps. - - If a form error is detected, add it to the form, - with 'form.add_error()' - - Ref: https://docs.djangoproject.com/en/dev/topics/forms/ - """ - # Do nothing by default - - def renderJsonResponse(self, request, form=None, data=None, context=None): - """Render a JSON response based on specific class context. - - Args: - request: HTTP request object (e.g. GET / POST) - form: Django form object (may be None) - data: Extra JSON data to pass to client - context: Extra context data to pass to template rendering - - Returns: - JSON response object - """ - # a empty dict as default can be dangerous - set it here if empty - if not data: - data = {} - - if not is_ajax(request): - return HttpResponseRedirect('/') - - if context is None: - try: - context = self.get_context_data() - except AttributeError: - context = {} - - # If no 'form' argument is supplied, look at the underlying class - if form is None: - try: - form = self.get_form() - except AttributeError: - pass - - if form: - context['form'] = form - else: - context['form'] = None - - data['title'] = self.get_form_title() - - data['html_form'] = render_to_string( - self.ajax_template_name, context, request=request - ) - - # Custom feedback`data - fb = self.get_data() - - for key in fb: - data[key] = fb[key] - - return JsonResponse(data, safe=False) - - -class AjaxView(AjaxMixin, View): - """An 'AJAXified' View for displaying an object.""" - - def post(self, request, *args, **kwargs): - """Return a json formatted response. - - This renderJsonResponse function must be supplied by your function. - """ - return self.renderJsonResponse(request) - - def get(self, request, *args, **kwargs): - """Return a json formatted response. - - This renderJsonResponse function must be supplied by your function. - """ - return self.renderJsonResponse(request) - - -class AjaxUpdateView(AjaxMixin, UpdateView): - """An 'AJAXified' UpdateView for updating an object in the db. - - - Returns form in JSON format (for delivery to a modal window) - - Handles repeated form validation (via AJAX) until the form is valid - """ - - def get(self, request, *args, **kwargs): - """Respond to GET request. - - - Populates form with object data - - Renders form to JSON and returns to client - """ - super(UpdateView, self).get(request, *args, **kwargs) - - return self.renderJsonResponse( - request, self.get_form(), context=self.get_context_data() - ) - - def save(self, obj, form, **kwargs): - """Method for updating the object in the database. Default implementation is very simple, but can be overridden if required. - - Args: - obj: The current object, to be updated - form: The validated form - - Returns: - object instance for supplied form - """ - self.object = form.save() - - return self.object - - def post(self, request, *args, **kwargs): - """Respond to POST request. - - - Updates model with POST field data - - Performs form and object validation - - If errors exist, re-render the form - - Otherwise, return success status - """ - self.request = request - - # Make sure we have an object to point to - self.object = self.get_object() - - form = self.get_form() - - # Perform initial form validation - form.is_valid() - - # Perform custom validation - self.validate(self.object, form) - - valid = form.is_valid() - - data = { - 'form_valid': valid, - 'form_errors': form.errors.as_json(), - 'non_field_errors': form.non_field_errors().as_json(), - } - - # Add in any extra class data - for value, key in enumerate(self.get_data()): - data[key] = value - - if valid: - # Save the updated object to the database - self.save(self.object, form) - - self.object = self.get_object() - - # Include context data about the updated object - data['pk'] = self.object.pk - - try: - data['url'] = self.object.get_absolute_url() - except AttributeError: - pass - - return self.renderJsonResponse(request, form, data) diff --git a/src/backend/InvenTree/build/admin.py b/src/backend/InvenTree/build/admin.py index 1a12166fa4..c6732109a3 100644 --- a/src/backend/InvenTree/build/admin.py +++ b/src/backend/InvenTree/build/admin.py @@ -1,117 +1,36 @@ -"""Admin functionality for the BuildOrder app""" +"""Admin functionality for the BuildOrder app.""" from django.contrib import admin -from import_export.admin import ImportExportModelAdmin -from import_export.fields import Field -from import_export import widgets - -from build.models import Build, BuildLine, BuildItem -from InvenTree.admin import InvenTreeResource -import part.models - - -class BuildResource(InvenTreeResource): - """Class for managing import/export of Build data.""" - # For some reason, we need to specify the fields individually for this ModelResource, - # but we don't for other ones. - # TODO: 2022-05-12 - Need to investigate why this is the case! - - class Meta: - """Metaclass options.""" - models = Build - skip_unchanged = True - report_skipped = False - clean_model_instances = True - exclude = [ - 'lft', 'rght', 'tree_id', 'level', - 'metadata', - ] - - id = Field(attribute='pk', widget=widgets.IntegerWidget()) - - reference = Field(attribute='reference') - - title = Field(attribute='title') - - part = Field(attribute='part', widget=widgets.ForeignKeyWidget(part.models.Part)) - - part_name = Field(attribute='part__full_name', readonly=True) - - overdue = Field(attribute='is_overdue', readonly=True, widget=widgets.BooleanWidget()) - - completed = Field(attribute='completed', readonly=True) - - quantity = Field(attribute='quantity') - - status = Field(attribute='status') - - batch = Field(attribute='batch') - - notes = Field(attribute='notes') +from build.models import Build, BuildItem, BuildLine @admin.register(Build) -class BuildAdmin(ImportExportModelAdmin): - """Class for managing the Build model via the admin interface""" +class BuildAdmin(admin.ModelAdmin): + """Class for managing the Build model via the admin interface.""" - exclude = [ - 'reference_int', - ] + exclude = ['reference_int'] - list_display = ( - 'reference', - 'title', - 'part', - 'status', - 'batch', - 'quantity', - ) + list_display = ('reference', 'title', 'part', 'status', 'batch', 'quantity') - search_fields = [ - 'reference', - 'title', - 'part__name', - 'part__description', - ] + search_fields = ['reference', 'title', 'part__name', 'part__description'] - autocomplete_fields = [ - 'parent', - 'part', - 'sales_order', - 'take_from', - 'destination', - ] + autocomplete_fields = ['parent', 'part', 'sales_order', 'take_from', 'destination'] @admin.register(BuildItem) class BuildItemAdmin(admin.ModelAdmin): """Class for managing the BuildItem model via the admin interface.""" - list_display = ( - 'stock_item', - 'quantity' - ) + list_display = ('stock_item', 'quantity') - autocomplete_fields = [ - 'build_line', - 'stock_item', - 'install_into', - ] + autocomplete_fields = ['build_line', 'stock_item', 'install_into'] @admin.register(BuildLine) class BuildLineAdmin(admin.ModelAdmin): - """Class for managing the BuildLine model via the admin interface""" + """Class for managing the BuildLine model via the admin interface.""" - list_display = ( - 'build', - 'bom_item', - 'quantity', - ) + list_display = ('build', 'bom_item', 'quantity') - search_fields = [ - 'build__title', - 'build__reference', - 'bom_item__sub_part__name', - ] + search_fields = ['build__title', 'build__reference', 'bom_item__sub_part__name'] diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 0eeb17ed61..8623bd8e8c 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -1,29 +1,28 @@ """JSON API for the Build app.""" from __future__ import annotations + +from django.contrib.auth.models import User from django.db.models import F, Q from django.urls import include, path from django.utils.translation import gettext_lazy as _ -from django.contrib.auth.models import User from django_filters import rest_framework as rest_filters from rest_framework.exceptions import ValidationError -from importer.mixins import DataExportViewMixin - -from InvenTree.api import BulkDeleteMixin, MetadataView -from generic.states.api import StatusView -from InvenTree.helpers import str2bool, isNull -from build.status_codes import BuildStatus, BuildStatusGroups -from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI - -import common.models import build.admin import build.serializers -from build.models import Build, BuildLine, BuildItem +import common.models import part.models +from build.models import Build, BuildItem, BuildLine +from build.status_codes import BuildStatus, BuildStatusGroups +from generic.states.api import StatusView +from importer.mixins import DataExportViewMixin +from InvenTree.api import BulkDeleteMixin, MetadataView +from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter +from InvenTree.helpers import isNull, str2bool +from InvenTree.mixins import CreateAPI, ListCreateAPI, RetrieveUpdateDestroyAPI from users.models import Owner -from InvenTree.filters import InvenTreeDateFilter, SEARCH_ORDER_FILTER_ALIAS class BuildFilter(rest_filters.FilterSet): @@ -31,17 +30,18 @@ class BuildFilter(rest_filters.FilterSet): class Meta: """Metaclass options.""" + model = Build - fields = [ - 'sales_order', - ] + fields = ['sales_order'] status = rest_filters.NumberFilter(label='Status') active = rest_filters.BooleanFilter(label='Build is active', method='filter_active') # 'outstanding' is an alias for 'active' here - outstanding = rest_filters.BooleanFilter(label='Build is outstanding', method='filter_active') + outstanding = rest_filters.BooleanFilter( + label='Build is outstanding', method='filter_active' + ) def filter_active(self, queryset, name, value): """Filter the queryset to either include or exclude orders which are active.""" @@ -50,12 +50,12 @@ class BuildFilter(rest_filters.FilterSet): return queryset.exclude(status__in=BuildStatusGroups.ACTIVE_CODES) parent = rest_filters.ModelChoiceFilter( - queryset=Build.objects.all(), - label=_('Parent Build'), - field_name='parent', + queryset=Build.objects.all(), label=_('Parent Build'), field_name='parent' ) - include_variants = rest_filters.BooleanFilter(label=_('Include Variants'), method='filter_include_variants') + include_variants = rest_filters.BooleanFilter( + label=_('Include Variants'), method='filter_include_variants' + ) def filter_include_variants(self, queryset, name, value): """Filter by whether or not to include variants of the selected part. @@ -64,13 +64,10 @@ class BuildFilter(rest_filters.FilterSet): - This filter does nothing by itself, and requires the 'part' filter to be set. - Refer to the 'filter_part' method for more information. """ - return queryset part = rest_filters.ModelChoiceFilter( - queryset=part.models.Part.objects.all(), - field_name='part', - method='filter_part' + queryset=part.models.Part.objects.all(), field_name='part', method='filter_part' ) def filter_part(self, queryset, name, part): @@ -80,7 +77,6 @@ class BuildFilter(rest_filters.FilterSet): - If "include_variants" is True, include all variants of the selected part. - Otherwise, just filter by the selected part. """ - include_variants = str2bool(self.data.get('include_variants', False)) if include_variants: @@ -91,16 +87,17 @@ class BuildFilter(rest_filters.FilterSet): ancestor = rest_filters.ModelChoiceFilter( queryset=Build.objects.all(), label=_('Ancestor Build'), - method='filter_ancestor' + method='filter_ancestor', ) def filter_ancestor(self, queryset, name, parent): """Filter by 'parent' build order.""" - builds = parent.get_descendants(include_self=False) return queryset.filter(pk__in=[b.pk for b in builds]) - overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue') + overdue = rest_filters.BooleanFilter( + label='Build is overdue', method='filter_overdue' + ) def filter_overdue(self, queryset, name, value): """Filter the queryset to either include or exclude orders which are overdue.""" @@ -109,8 +106,7 @@ class BuildFilter(rest_filters.FilterSet): return queryset.exclude(Build.OVERDUE_FILTER) assigned_to_me = rest_filters.BooleanFilter( - label=_('Assigned to me'), - method='filter_assigned_to_me' + label=_('Assigned to me'), method='filter_assigned_to_me' ) def filter_assigned_to_me(self, queryset, name, value): @@ -125,14 +121,11 @@ class BuildFilter(rest_filters.FilterSet): return queryset.exclude(responsible__in=owners) issued_by = rest_filters.ModelChoiceFilter( - queryset=Owner.objects.all(), - label=_('Issued By'), - method='filter_issued_by' + queryset=Owner.objects.all(), label=_('Issued By'), method='filter_issued_by' ) def filter_issued_by(self, queryset, name, owner): """Filter by 'owner' which issued the order.""" - if owner.label() == 'user': user = User.objects.get(pk=owner.owner_id) return queryset.filter(issued_by=user) @@ -143,70 +136,62 @@ class BuildFilter(rest_filters.FilterSet): return queryset.none() assigned_to = rest_filters.ModelChoiceFilter( - queryset=Owner.objects.all(), - field_name='responsible', - label=_('Assigned To') + queryset=Owner.objects.all(), field_name='responsible', label=_('Assigned To') ) def filter_responsible(self, queryset, name, owner): """Filter by orders which are assigned to the specified owner.""" - owners = list(Owner.objects.filter(pk=owner)) # if we query by a user, also find all ownerships through group memberships if len(owners) > 0 and owners[0].label() == 'user': - owners = Owner.get_owners_matching_user(User.objects.get(pk=owners[0].owner_id)) + owners = Owner.get_owners_matching_user( + User.objects.get(pk=owners[0].owner_id) + ) return queryset.filter(responsible__in=owners) # Exact match for reference reference = rest_filters.CharFilter( - label='Filter by exact reference', - field_name='reference', - lookup_expr="iexact" + label='Filter by exact reference', field_name='reference', lookup_expr='iexact' ) project_code = rest_filters.ModelChoiceFilter( - queryset=common.models.ProjectCode.objects.all(), - field_name='project_code' + queryset=common.models.ProjectCode.objects.all(), field_name='project_code' ) - has_project_code = rest_filters.BooleanFilter(label='has_project_code', method='filter_has_project_code') + has_project_code = rest_filters.BooleanFilter( + label='has_project_code', method='filter_has_project_code' + ) def filter_has_project_code(self, queryset, name, value): - """Filter by whether or not the order has a project code""" + """Filter by whether or not the order has a project code.""" if str2bool(value): return queryset.exclude(project_code=None) return queryset.filter(project_code=None) created_before = InvenTreeDateFilter( - label=_('Created before'), - field_name='creation_date', lookup_expr='lt'\ + label=_('Created before'), field_name='creation_date', lookup_expr='lt' ) created_after = InvenTreeDateFilter( - label=_('Created after'), - field_name='creation_date', lookup_expr='gt' + label=_('Created after'), field_name='creation_date', lookup_expr='gt' ) target_date_before = InvenTreeDateFilter( - label=_('Target date before'), - field_name='target_date', lookup_expr='lt' + label=_('Target date before'), field_name='target_date', lookup_expr='lt' ) target_date_after = InvenTreeDateFilter( - label=_('Target date after'), - field_name='target_date', lookup_expr='gt' + label=_('Target date after'), field_name='target_date', lookup_expr='gt' ) completed_before = InvenTreeDateFilter( - label=_('Completed before'), - field_name='completion_date', lookup_expr='lt' + label=_('Completed before'), field_name='completion_date', lookup_expr='lt' ) completed_after = InvenTreeDateFilter( - label=_('Completed after'), - field_name='completion_date', lookup_expr='gt' + label=_('Completed after'), field_name='completion_date', lookup_expr='gt' ) @@ -227,7 +212,7 @@ class BuildMixin: 'build_lines__bom_item', 'build_lines__build', 'part', - 'part__pricing_data' + 'part__pricing_data', ) return queryset @@ -295,7 +280,6 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI): exclude_tree = params.get('exclude_tree', None) if exclude_tree is not None: - try: build = Build.objects.get(pk=exclude_tree) @@ -332,12 +316,14 @@ class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a Build object.""" def destroy(self, request, *args, **kwargs): - """Only allow deletion of a BuildOrder if the build status is CANCELLED""" + """Only allow deletion of a BuildOrder if the build status is CANCELLED.""" build = self.get_object() if build.status != BuildStatus.CANCELLED: raise ValidationError({ - "non_field_errors": [_("Build must be cancelled before it can be deleted")] + 'non_field_errors': [ + _('Build must be cancelled before it can be deleted') + ] }) return super().destroy(request, *args, **kwargs) @@ -374,18 +360,26 @@ class BuildLineFilter(rest_filters.FilterSet): class Meta: """Meta information for the BuildLineFilter class.""" + model = BuildLine - fields = [ - 'build', - 'bom_item', - ] + fields = ['build', 'bom_item'] # Fields on related models - consumable = rest_filters.BooleanFilter(label=_('Consumable'), field_name='bom_item__consumable') - optional = rest_filters.BooleanFilter(label=_('Optional'), field_name='bom_item__optional') - assembly = rest_filters.BooleanFilter(label=_('Assembly'), field_name='bom_item__sub_part__assembly') - tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable') - testable = rest_filters.BooleanFilter(label=_('Testable'), field_name='bom_item__sub_part__testable') + consumable = rest_filters.BooleanFilter( + label=_('Consumable'), field_name='bom_item__consumable' + ) + optional = rest_filters.BooleanFilter( + label=_('Optional'), field_name='bom_item__optional' + ) + assembly = rest_filters.BooleanFilter( + label=_('Assembly'), field_name='bom_item__sub_part__assembly' + ) + tracked = rest_filters.BooleanFilter( + label=_('Tracked'), field_name='bom_item__sub_part__trackable' + ) + testable = rest_filters.BooleanFilter( + label=_('Testable'), field_name='bom_item__sub_part__testable' + ) part = rest_filters.ModelChoiceFilter( queryset=part.models.Part.objects.all(), @@ -394,8 +388,7 @@ class BuildLineFilter(rest_filters.FilterSet): ) order_outstanding = rest_filters.BooleanFilter( - label=_('Order Outstanding'), - method='filter_order_outstanding' + label=_('Order Outstanding'), method='filter_order_outstanding' ) def filter_order_outstanding(self, queryset, name, value): @@ -404,18 +397,22 @@ class BuildLineFilter(rest_filters.FilterSet): return queryset.filter(build__status__in=BuildStatusGroups.ACTIVE_CODES) return queryset.exclude(build__status__in=BuildStatusGroups.ACTIVE_CODES) - allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated') + allocated = rest_filters.BooleanFilter( + label=_('Allocated'), method='filter_allocated' + ) def filter_allocated(self, queryset, name, value): - """Filter by whether each BuildLine is fully allocated""" + """Filter by whether each BuildLine is fully allocated.""" if str2bool(value): return queryset.filter(allocated__gte=F('quantity')) return queryset.filter(allocated__lt=F('quantity')) - available = rest_filters.BooleanFilter(label=_('Available'), method='filter_available') + available = rest_filters.BooleanFilter( + label=_('Available'), method='filter_available' + ) def filter_available(self, queryset, name, value): - """Filter by whether there is sufficient stock available for each BuildLine: + """Filter by whether there is sufficient stock available for each BuildLine. To determine this, we need to know: @@ -423,14 +420,18 @@ class BuildLineFilter(rest_filters.FilterSet): - The quantity available for each BuildLine (including variants and substitutes) - The quantity allocated for each BuildLine """ - flt = Q(quantity__lte=F('allocated') + F('available_stock') + F('available_substitute_stock') + F('available_variant_stock')) + flt = Q( + quantity__lte=F('allocated') + + F('available_stock') + + F('available_substitute_stock') + + F('available_variant_stock') + ) if str2bool(value): return queryset.filter(flt) return queryset.exclude(flt) - class BuildLineEndpoint: """Mixin class for BuildLine API endpoints.""" @@ -439,7 +440,6 @@ class BuildLineEndpoint: def get_serializer(self, *args, **kwargs): """Return the serializer instance for this endpoint.""" - kwargs['context'] = self.get_serializer_context() try: @@ -460,10 +460,12 @@ class BuildLineEndpoint: - If this is a "detail" view, use the build associated with the line - If this is a "list" view, use the build associated with the request """ - raise NotImplementedError("get_source_build must be implemented in the child class") + raise NotImplementedError( + 'get_source_build must be implemented in the child class' + ) def get_queryset(self): - """Override queryset to select-related and annotate""" + """Override queryset to select-related and annotate.""" queryset = super().get_queryset() if not hasattr(self, 'source_build'): @@ -471,11 +473,13 @@ class BuildLineEndpoint: source_build = self.source_build - return build.serializers.BuildLineSerializer.annotate_queryset(queryset, build=source_build) + return build.serializers.BuildLineSerializer.annotate_queryset( + queryset, build=source_build + ) class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI): - """API endpoint for accessing a list of BuildLine objects""" + """API endpoint for accessing a list of BuildLine objects.""" filterset_class = BuildLineFilter filter_backends = SEARCH_ORDER_FILTER_ALIAS @@ -514,7 +518,6 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI): def get_source_build(self) -> Build | None: """Return the target build for the BuildLine queryset.""" - source_build = None try: @@ -532,7 +535,6 @@ class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI): def get_source_build(self) -> Build | None: """Return the target source location for the BuildLine queryset.""" - return None @@ -607,12 +609,8 @@ class BuildFinish(BuildOrderContextMixin, CreateAPI): def get_queryset(self): """Return the queryset for the BuildFinish API endpoint.""" - queryset = super().get_queryset() - queryset = queryset.prefetch_related( - 'build_lines', - 'build_lines__allocations' - ) + queryset = queryset.prefetch_related('build_lines', 'build_lines__allocations') return queryset @@ -658,6 +656,7 @@ class BuildHold(BuildOrderContextMixin, CreateAPI): queryset = Build.objects.all() serializer_class = build.serializers.BuildHoldSerializer + class BuildCancel(BuildOrderContextMixin, CreateAPI): """API endpoint for cancelling a BuildOrder.""" @@ -673,16 +672,13 @@ class BuildItemDetail(RetrieveUpdateDestroyAPI): class BuildItemFilter(rest_filters.FilterSet): - """Custom filterset for the BuildItemList API endpoint""" + """Custom filterset for the BuildItemList API endpoint.""" class Meta: - """Metaclass option""" + """Metaclass option.""" + model = BuildItem - fields = [ - 'build_line', - 'stock_item', - 'install_into', - ] + fields = ['build_line', 'stock_item', 'install_into'] include_variants = rest_filters.BooleanFilter( label=_('Include Variants'), method='filter_include_variants' @@ -695,7 +691,6 @@ class BuildItemFilter(rest_filters.FilterSet): - This filter does nothing by itself, and requires the 'part' filter to be set. - Refer to the 'filter_part' method for more information. """ - return queryset part = rest_filters.ModelChoiceFilter( @@ -712,11 +707,12 @@ class BuildItemFilter(rest_filters.FilterSet): - If "include_variants" is True, include all variants of the selected part. - Otherwise, just filter by the selected part. """ - include_variants = str2bool(self.data.get('include_variants', False)) if include_variants: - return queryset.filter(stock_item__part__in=part.get_descendants(include_self=True)) + return queryset.filter( + stock_item__part__in=part.get_descendants(include_self=True) + ) else: return queryset.filter(stock_item__part=part) @@ -729,7 +725,7 @@ class BuildItemFilter(rest_filters.FilterSet): tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked') def filter_tracked(self, queryset, name, value): - """Filter the queryset based on whether build items are tracked""" + """Filter the queryset based on whether build items are tracked.""" if str2bool(value): return queryset.exclude(install_into=None) return queryset.filter(install_into=None) @@ -752,7 +748,12 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): try: params = self.request.query_params - for key in ['part_detail', 'location_detail', 'stock_detail', 'build_detail']: + for key in [ + 'part_detail', + 'location_detail', + 'stock_detail', + 'build_detail', + ]: if key in params: kwargs[key] = str2bool(params.get(key, False)) except AttributeError: @@ -778,9 +779,7 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): 'stock_item__supplier_part__supplier', 'stock_item__supplier_part__manufacturer_part', 'stock_item__supplier_part__manufacturer_part__manufacturer', - ).prefetch_related( - 'stock_item__location__tags', - ) + ).prefetch_related('stock_item__location__tags') return queryset @@ -794,7 +793,6 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): output = params.get('output', None) if output: - if isNull(output): queryset = queryset.filter(install_into=None) else: @@ -802,14 +800,7 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): return queryset - ordering_fields = [ - 'part', - 'sku', - 'quantity', - 'location', - 'reference', - 'IPN', - ] + ordering_fields = ['part', 'sku', 'quantity', 'location', 'reference', 'IPN'] ordering_field_aliases = { 'part': 'stock_item__part__name', @@ -828,42 +819,84 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): build_api_urls = [ - # Build lines - path('line/', include([ - path('/', BuildLineDetail.as_view(), name='api-build-line-detail'), - path('', BuildLineList.as_view(), name='api-build-line-list'), - ])), - + path( + 'line/', + include([ + path('/', BuildLineDetail.as_view(), name='api-build-line-detail'), + path('', BuildLineList.as_view(), name='api-build-line-list'), + ]), + ), # Build Items - path('item/', include([ - path('/', include([ - path('metadata/', MetadataView.as_view(), {'model': BuildItem}, name='api-build-item-metadata'), - path('', BuildItemDetail.as_view(), name='api-build-item-detail'), - ])), - path('', BuildItemList.as_view(), name='api-build-item-list'), - ])), - + path( + 'item/', + include([ + path( + '/', + include([ + path( + 'metadata/', + MetadataView.as_view(), + {'model': BuildItem}, + name='api-build-item-metadata', + ), + path('', BuildItemDetail.as_view(), name='api-build-item-detail'), + ]), + ), + path('', BuildItemList.as_view(), name='api-build-item-list'), + ]), + ), # Build Detail - path('/', include([ - path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'), - path('auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'), - path('complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'), - path('create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'), - path('delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'), - path('scrap-outputs/', BuildOutputScrap.as_view(), name='api-build-output-scrap'), - path('issue/', BuildIssue.as_view(), name='api-build-issue'), - path('hold/', BuildHold.as_view(), name='api-build-hold'), - path('finish/', BuildFinish.as_view(), name='api-build-finish'), - path('cancel/', BuildCancel.as_view(), name='api-build-cancel'), - path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), - path('metadata/', MetadataView.as_view(), {'model': Build}, name='api-build-metadata'), - path('', BuildDetail.as_view(), name='api-build-detail'), - ])), - + path( + '/', + include([ + path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + path( + 'auto-allocate/', + BuildAutoAllocate.as_view(), + name='api-build-auto-allocate', + ), + path( + 'complete/', + BuildOutputComplete.as_view(), + name='api-build-output-complete', + ), + path( + 'create-output/', + BuildOutputCreate.as_view(), + name='api-build-output-create', + ), + path( + 'delete-outputs/', + BuildOutputDelete.as_view(), + name='api-build-output-delete', + ), + path( + 'scrap-outputs/', + BuildOutputScrap.as_view(), + name='api-build-output-scrap', + ), + path('issue/', BuildIssue.as_view(), name='api-build-issue'), + path('hold/', BuildHold.as_view(), name='api-build-hold'), + path('finish/', BuildFinish.as_view(), name='api-build-finish'), + path('cancel/', BuildCancel.as_view(), name='api-build-cancel'), + path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), + path( + 'metadata/', + MetadataView.as_view(), + {'model': Build}, + name='api-build-metadata', + ), + path('', BuildDetail.as_view(), name='api-build-detail'), + ]), + ), # Build order status code information - path('status/', StatusView.as_view(), {StatusView.MODEL_REF: BuildStatus}, name='api-build-status-codes'), - + path( + 'status/', + StatusView.as_view(), + {StatusView.MODEL_REF: BuildStatus}, + name='api-build-status-codes', + ), # Build List path('', BuildList.as_view(), name='api-build-list'), ] diff --git a/src/backend/InvenTree/build/apps.py b/src/backend/InvenTree/build/apps.py index 683e410b66..8a449b84db 100644 --- a/src/backend/InvenTree/build/apps.py +++ b/src/backend/InvenTree/build/apps.py @@ -1,8 +1,9 @@ -"""Django app for the BuildOrder module""" +"""Django app for the BuildOrder module.""" from django.apps import AppConfig class BuildConfig(AppConfig): - """BuildOrder app config class""" + """BuildOrder app config class.""" + name = 'build' diff --git a/src/backend/InvenTree/build/filters.py b/src/backend/InvenTree/build/filters.py index ff3c02a523..9b995d2de1 100644 --- a/src/backend/InvenTree/build/filters.py +++ b/src/backend/InvenTree/build/filters.py @@ -1,25 +1,21 @@ """Queryset filtering helper functions for the Build app.""" - from django.db import models -from django.db.models import Sum, Q +from django.db.models import Q, Sum from django.db.models.functions import Coalesce def annotate_allocated_quantity(queryset: Q) -> Q: - """ - Annotate the 'allocated' quantity for each build item in the queryset. + """Annotate the 'allocated' quantity for each build item in the queryset. Arguments: queryset: The BuildLine queryset to annotate """ - queryset = queryset.prefetch_related('allocations') return queryset.annotate( allocated=Coalesce( - Sum('allocations__quantity'), 0, - output_field=models.DecimalField() + Sum('allocations__quantity'), 0, output_field=models.DecimalField() ) ) diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 4002c63fc3..236cfbdb85 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -3,50 +3,49 @@ import decimal import logging from datetime import datetime -from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models, transaction -from django.db.models import F, Sum, Q +from django.db.models import F, Q, Sum from django.db.models.functions import Coalesce from django.db.models.signals import post_save from django.dispatch.dispatcher import receiver from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from mptt.models import MPTTModel, TreeForeignKey from mptt.exceptions import InvalidMove - +from mptt.models import MPTTModel, TreeForeignKey from rest_framework import serializers -from build.status_codes import BuildStatus, BuildStatusGroups -from stock.status_codes import StockStatus, StockHistoryCode - -from build.events import BuildEvents -from build.filters import annotate_allocated_quantity -from build.validators import generate_next_build_reference, validate_build_order_reference -from generic.states import StateTransitionMixin - +import generic.states import InvenTree.fields import InvenTree.helpers import InvenTree.helpers_model import InvenTree.models import InvenTree.ready import InvenTree.tasks - -import common.models -from common.notifications import trigger_notification, InvenTreeNotificationBodies -from common.settings import get_global_setting -from plugin.events import trigger_event - import part.models import report.mixins import stock.models import users.models -import generic.states - +from build.events import BuildEvents +from build.filters import annotate_allocated_quantity +from build.status_codes import BuildStatus, BuildStatusGroups +from build.validators import ( + generate_next_build_reference, + validate_build_order_reference, +) +from common.models import ProjectCode +from common.notifications import InvenTreeNotificationBodies, trigger_notification +from common.settings import ( + get_global_setting, + prevent_build_output_complete_on_incompleted_tests, +) +from generic.states import StateTransitionMixin +from plugin.events import trigger_event +from stock.status_codes import StockHistoryCode, StockStatus logger = logging.getLogger('inventree') @@ -60,7 +59,8 @@ class Build( InvenTree.models.PluginValidationMixin, InvenTree.models.ReferenceIndexingMixin, StateTransitionMixin, - MPTTModel): + MPTTModel, +): """A Build object organises the creation of new StockItem objects from other existing StockItem objects. Attributes: @@ -85,34 +85,33 @@ class Build( """ class Meta: - """Metaclass options for the BuildOrder model""" - verbose_name = _("Build Order") - verbose_name_plural = _("Build Orders") + """Metaclass options for the BuildOrder model.""" - OVERDUE_FILTER = Q(status__in=BuildStatusGroups.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=InvenTree.helpers.current_date()) + verbose_name = _('Build Order') + verbose_name_plural = _('Build Orders') + + OVERDUE_FILTER = ( + Q(status__in=BuildStatusGroups.ACTIVE_CODES) + & ~Q(target_date=None) + & Q(target_date__lte=InvenTree.helpers.current_date()) + ) # Global setting for specifying reference pattern REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN' @staticmethod def get_api_url(): - """Return the API URL associated with the BuildOrder model""" + """Return the API URL associated with the BuildOrder model.""" return reverse('api-build-list') def api_instance_filters(self): - """Returns custom API filters for the particular BuildOrder instance""" - return { - 'parent': { - 'exclude_tree': self.pk, - } - } + """Returns custom API filters for the particular BuildOrder instance.""" + return {'parent': {'exclude_tree': self.pk}} @classmethod def api_defaults(cls, request=None): """Return default values for this model when issuing an API OPTIONS request.""" - defaults = { - 'reference': generate_next_build_reference(), - } + defaults = {'reference': generate_next_build_reference()} if request and request.user: defaults['issued_by'] = request.user.pk @@ -122,10 +121,10 @@ class Build( @classmethod def barcode_model_type_code(cls): """Return the associated barcode model type code for this model.""" - return "BO" + return 'BO' def save(self, *args, **kwargs): - """Custom save method for the BuildOrder model""" + """Custom save method for the BuildOrder model.""" self.reference_int = self.validate_reference_field(self.reference) # Check part when initially creating the build order @@ -153,7 +152,6 @@ class Build( # On first save (i.e. creation), run some extra checks if self.pk is None: - # Set the destination location (if not specified) if not self.destination: self.destination = self.part.get_default_location() @@ -161,13 +159,10 @@ class Build( try: super().save(*args, **kwargs) except InvalidMove: - raise ValidationError({ - 'parent': _('Invalid choice for parent build'), - }) + raise ValidationError({'parent': _('Invalid choice for parent build')}) def clean(self): - """Validate the BuildOrder model""" - + """Validate the BuildOrder model.""" super().clean() if get_global_setting('BUILDORDER_REQUIRE_RESPONSIBLE'): @@ -178,13 +173,10 @@ class Build( # Prevent changing target part after creation if self.has_field_changed('part'): - raise ValidationError({ - 'part': _('Build order part cannot be changed') - }) + raise ValidationError({'part': _('Build order part cannot be changed')}) def report_context(self) -> dict: """Generate custom report context data.""" - return { 'bom_items': self.part.get_bom_items(), 'build': self, @@ -193,10 +185,9 @@ class Build( 'part': self.part, 'quantity': self.quantity, 'reference': self.reference, - 'title': str(self) + 'title': str(self), } - @staticmethod def filterByDate(queryset, min_date, max_date): """Filter by 'minimum and maximum date range'. @@ -215,10 +206,19 @@ class Build( return queryset # Order was completed within the specified range - completed = Q(status=BuildStatus.COMPLETE.value) & Q(completion_date__gte=min_date) & Q(completion_date__lte=max_date) + completed = ( + Q(status=BuildStatus.COMPLETE.value) + & Q(completion_date__gte=min_date) + & Q(completion_date__lte=max_date) + ) # Order target date falls within specified range - pending = Q(status__in=BuildStatusGroups.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date) + pending = ( + Q(status__in=BuildStatusGroups.ACTIVE_CODES) + & ~Q(target_date=None) + & Q(target_date__gte=min_date) + & Q(target_date__lte=max_date) + ) # TODO - Construct a queryset for "overdue" orders @@ -227,11 +227,11 @@ class Build( return queryset def __str__(self): - """String representation of a BuildOrder""" + """String representation of a BuildOrder.""" return self.reference def get_absolute_url(self): - """Return the web URL associated with this BuildOrder""" + """Return the web URL associated with this BuildOrder.""" return InvenTree.helpers.pui_url(f'/manufacturing/build-order/{self.id}') reference = models.CharField( @@ -241,22 +241,21 @@ class Build( help_text=_('Build Order Reference'), verbose_name=_('Reference'), default=generate_next_build_reference, - validators=[ - validate_build_order_reference, - ] + validators=[validate_build_order_reference], ) title = models.CharField( verbose_name=_('Description'), blank=True, max_length=100, - help_text=_('Brief description of the build (optional)') + help_text=_('Brief description of the build (optional)'), ) parent = TreeForeignKey( 'self', on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, related_name='children', verbose_name=_('Parent Build'), help_text=_('BuildOrder to which this build is allocated'), @@ -267,9 +266,7 @@ class Build( verbose_name=_('Part'), on_delete=models.CASCADE, related_name='builds', - limit_choices_to={ - 'assembly': True, - }, + limit_choices_to={'assembly': True}, help_text=_('Select part to build'), ) @@ -278,8 +275,9 @@ class Build( verbose_name=_('Sales Order Reference'), on_delete=models.SET_NULL, related_name='builds', - null=True, blank=True, - help_text=_('SalesOrder to which this build is allocated') + null=True, + blank=True, + help_text=_('SalesOrder to which this build is allocated'), ) take_from = models.ForeignKey( @@ -287,8 +285,11 @@ class Build( verbose_name=_('Source Location'), on_delete=models.SET_NULL, related_name='sourcing_builds', - null=True, blank=True, - help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)') + null=True, + blank=True, + help_text=_( + 'Select location to take stock from for this build (leave blank to take from any stock location)' + ), ) destination = models.ForeignKey( @@ -296,7 +297,8 @@ class Build( verbose_name=_('Destination Location'), on_delete=models.SET_NULL, related_name='incoming_builds', - null=True, blank=True, + null=True, + blank=True, help_text=_('Select location where the completed items will be stored'), ) @@ -304,13 +306,13 @@ class Build( verbose_name=_('Build Quantity'), default=1, validators=[MinValueValidator(1)], - help_text=_('Number of stock items to build') + help_text=_('Number of stock items to build'), ) completed = models.PositiveIntegerField( verbose_name=_('Completed items'), default=0, - help_text=_('Number of stock items which have been completed') + help_text=_('Number of stock items which have been completed'), ) status = generic.states.fields.InvenTreeCustomStatusModelField( @@ -318,12 +320,12 @@ class Build( default=BuildStatus.PENDING.value, choices=BuildStatus.items(), validators=[MinValueValidator(0)], - help_text=_('Build status code') + help_text=_('Build status code'), ) @property def status_text(self): - """Return the text representation of the status field""" + """Return the text representation of the status field.""" return BuildStatus.text(self.status) batch = models.CharField( @@ -331,31 +333,40 @@ class Build( max_length=100, blank=True, null=True, - help_text=_('Batch code for this build output') + help_text=_('Batch code for this build output'), ) - creation_date = models.DateField(auto_now_add=True, editable=False, verbose_name=_('Creation Date')) + creation_date = models.DateField( + auto_now_add=True, editable=False, verbose_name=_('Creation Date') + ) target_date = models.DateField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Target completion date'), - help_text=_('Target date for build completion. Build will be overdue after this date.') + help_text=_( + 'Target date for build completion. Build will be overdue after this date.' + ), ) - completion_date = models.DateField(null=True, blank=True, verbose_name=_('Completion Date')) + completion_date = models.DateField( + null=True, blank=True, verbose_name=_('Completion Date') + ) completed_by = models.ForeignKey( User, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('completed by'), - related_name='builds_completed' + related_name='builds_completed', ) issued_by = models.ForeignKey( User, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Issued by'), help_text=_('User who issued this build order'), related_name='builds_issued', @@ -364,28 +375,29 @@ class Build( responsible = models.ForeignKey( users.models.Owner, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Responsible'), help_text=_('User or group responsible for this build order'), related_name='builds_responsible', ) link = InvenTree.fields.InvenTreeURLField( - verbose_name=_('External Link'), - blank=True, help_text=_('Link to external URL') + verbose_name=_('External Link'), blank=True, help_text=_('Link to external URL') ) priority = models.PositiveIntegerField( verbose_name=_('Build Priority'), default=0, validators=[MinValueValidator(0)], - help_text=_('Priority of this build order') + help_text=_('Priority of this build order'), ) project_code = models.ForeignKey( - common.models.ProjectCode, + ProjectCode, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Project Code'), help_text=_('Project code for this build order'), ) @@ -408,7 +420,9 @@ class Build( @property def has_open_child_builds(self): """Return True if this build order has any open child builds.""" - return self.sub_builds().filter(status__in=BuildStatusGroups.ACTIVE_CODES).exists() + return ( + self.sub_builds().filter(status__in=BuildStatusGroups.ACTIVE_CODES).exists() + ) @property def is_overdue(self): @@ -459,11 +473,11 @@ class Build( @property def output_count(self): - """Return the number of build outputs (StockItem) associated with this build order""" + """Return the number of build outputs (StockItem) associated with this build order.""" return self.build_outputs.count() def has_build_outputs(self): - """Returns True if this build has more than zero build outputs""" + """Returns True if this build has more than zero build outputs.""" return self.output_count > 0 def get_build_outputs(self, **kwargs): @@ -476,7 +490,7 @@ class Build( outputs = self.build_outputs.all() # Filter by 'in stock' status - in_stock = kwargs.get('in_stock', None) + in_stock = kwargs.get('in_stock') if in_stock is not None: if in_stock: @@ -485,7 +499,7 @@ class Build( outputs = outputs.exclude(stock.models.StockItem.IN_STOCK_FILTER) # Filter by 'complete' status - complete = kwargs.get('complete', None) + complete = kwargs.get('complete') if complete is not None: if complete: @@ -504,7 +518,7 @@ class Build( @property def complete_count(self): - """Return the total quantity of completed outputs""" + """Return the total quantity of completed outputs.""" quantity = 0 for output in self.complete_outputs: @@ -513,7 +527,7 @@ class Build( return quantity def is_partially_allocated(self): - """Test is this build order has any stock allocated against it""" + """Test is this build order has any stock allocated against it.""" return self.allocated_stock.count() > 0 @property @@ -572,14 +586,16 @@ class Build( @property def can_complete(self): - """Returns True if this BuildOrder is ready to be completed + """Returns True if this BuildOrder is ready to be completed. - Must not have any outstanding build outputs - Completed count must meet the required quantity - Untracked parts must be allocated """ - - if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and self.has_open_child_builds: + if ( + get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') + and self.has_open_child_builds + ): return False if self.status != BuildStatus.PRODUCTION.value: @@ -591,10 +607,7 @@ class Build( if self.remaining > 0: return False - if not self.is_fully_allocated(tracked=False): - return False - - return True + return self.is_fully_allocated(tracked=False) @transaction.atomic def complete_allocations(self, user): @@ -612,21 +625,27 @@ class Build( @transaction.atomic def complete_build(self, user, trim_allocated_stock=False): """Mark this build as complete.""" - return self.handle_transition( - self.status, BuildStatus.COMPLETE.value, self, self._action_complete, user=user, trim_allocated_stock=trim_allocated_stock + self.status, + BuildStatus.COMPLETE.value, + self, + self._action_complete, + user=user, + trim_allocated_stock=trim_allocated_stock, ) def _action_complete(self, *args, **kwargs): """Action to be taken when a build is completed.""" - import build.tasks trim_allocated_stock = kwargs.pop('trim_allocated_stock', False) user = kwargs.pop('user', None) # Prevent completion if there are open child builds - if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and self.has_open_child_builds: + if ( + get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') + and self.has_open_child_builds + ): return if self.incomplete_count > 0: @@ -645,18 +664,17 @@ class Build( build.tasks.complete_build_allocations, self.pk, user.pk if user else None, - group='build' + group='build', ): - raise ValidationError(_("Failed to offload task to complete build allocations")) + raise ValidationError( + _('Failed to offload task to complete build allocations') + ) # Register an event trigger_event(BuildEvents.COMPLETED, id=self.pk) # Notify users that this build has been completed - targets = [ - self.issued_by, - self.responsible, - ] + targets = [self.issued_by, self.responsible] # Notify those users interested in the parent build if self.parent: @@ -676,11 +694,10 @@ class Build( 'name': name, 'slug': 'build.completed', 'message': _('A build order has been completed'), - 'link': InvenTree.helpers_model.construct_absolute_url(self.get_absolute_url()), - 'template': { - 'html': 'email/build_order_completed.html', - 'subject': name, - } + 'link': InvenTree.helpers_model.construct_absolute_url( + self.get_absolute_url() + ), + 'template': {'html': 'email/build_order_completed.html', 'subject': name}, } trigger_notification( @@ -705,14 +722,10 @@ class Build( @property def can_issue(self): """Returns True if this BuildOrder can be issued.""" - return self.status in [ - BuildStatus.PENDING.value, - BuildStatus.ON_HOLD.value, - ] + return self.status in [BuildStatus.PENDING.value, BuildStatus.ON_HOLD.value] def _action_issue(self, *args, **kwargs): """Perform the action to mark this order as PRODUCTION.""" - if self.can_issue: self.status = BuildStatus.PRODUCTION.value self.save() @@ -722,22 +735,17 @@ class Build( @transaction.atomic def hold_build(self): """Mark the Build as ON HOLD.""" - return self.handle_transition( self.status, BuildStatus.ON_HOLD.value, self, self._action_hold ) @property def can_hold(self): - """Returns True if this BuildOrder can be placed on hold""" - return self.status in [ - BuildStatus.PENDING.value, - BuildStatus.PRODUCTION.value, - ] + """Returns True if this BuildOrder can be placed on hold.""" + return self.status in [BuildStatus.PENDING.value, BuildStatus.PRODUCTION.value] def _action_hold(self, *args, **kwargs): """Action to be taken when a build is placed on hold.""" - if self.can_hold: self.status = BuildStatus.ON_HOLD.value self.save() @@ -752,14 +760,17 @@ class Build( - Set build status to CANCELLED - Save the Build object """ - return self.handle_transition( - self.status, BuildStatus.CANCELLED.value, self, self._action_cancel, user=user, **kwargs + self.status, + BuildStatus.CANCELLED.value, + self, + self._action_cancel, + user=user, + **kwargs, ) def _action_cancel(self, *args, **kwargs): """Action to be taken when a build is cancelled.""" - import build.tasks user = kwargs.pop('user', None) @@ -775,7 +786,9 @@ class Build( user.pk if user else None, group='build', ): - raise ValidationError(_("Failed to offload task to complete build allocations")) + raise ValidationError( + _('Failed to offload task to complete build allocations') + ) else: self.allocated_stock.all().delete() @@ -798,7 +811,7 @@ class Build( self, Build, exclude=self.issued_by, - content=InvenTreeNotificationBodies.OrderCanceled + content=InvenTreeNotificationBodies.OrderCanceled, ) trigger_event(BuildEvents.CANCELLED, id=self.pk) @@ -811,9 +824,7 @@ class Build( build_line: Specify a particular BuildLine instance to un-allocate stock against output: Specify a particular StockItem (output) to un-allocate stock against """ - allocations = self.allocated_stock.filter( - install_into=output - ) + allocations = self.allocated_stock.filter(install_into=output) if build_line: allocations = allocations.filter(build_line=build_line) @@ -833,7 +844,6 @@ class Build( location: Override location auto_allocate: Automatically allocate stock with matching serial numbers """ - trackable_parts = self.part.get_trackable_parts() # Create (and cache) a map of valid parts for allocation @@ -841,12 +851,12 @@ class Build( for bom_item in trackable_parts: parts = bom_item.get_valid_parts_for_allocation() - valid_parts[bom_item.pk] = list([part.pk for part in parts]) + valid_parts[bom_item.pk] = [part.pk for part in parts] - user = kwargs.get('user', None) + user = kwargs.get('user') batch = kwargs.get('batch', self.batch) - location = kwargs.get('location', None) - serials = kwargs.get('serials', None) + location = kwargs.get('location') + serials = kwargs.get('serials') auto_allocate = kwargs.get('auto_allocate', False) if location is None: @@ -854,7 +864,7 @@ class Build( if self.part.has_trackable_parts and not serials: raise ValidationError({ - 'serials': _("Serial numbers must be provided for trackable parts") + 'serials': _('Serial numbers must be provided for trackable parts') }) # We are generating multiple serialized outputs @@ -871,7 +881,7 @@ class Build( build=self, batch=batch, location=location, - is_building=True + is_building=True, ) for output in outputs: @@ -884,15 +894,14 @@ class Build( 'buildorder': self.pk, 'batch': output.batch, 'serial': output.serial, - 'location': location.pk if location else None + 'location': location.pk if location else None, }, - commit=False + commit=False, ): tracking.append(entry) # Auto-allocate stock based on serial number if auto_allocate: - for bom_item in trackable_parts: valid_part_ids = valid_parts.get(bom_item.pk, []) @@ -908,8 +917,7 @@ class Build( # Find the 'BuildLine' object which points to this BomItem try: build_line = BuildLine.objects.get( - build=self, - bom_item=bom_item + build=self, bom_item=bom_item ) # Allocate the stock items against the BuildLine @@ -939,7 +947,7 @@ class Build( part=self.part, build=self, batch=batch, - is_building=True + is_building=True, ) output.add_tracking_entry( @@ -949,8 +957,8 @@ class Build( 'quantity': float(quantity), 'buildorder': self.pk, 'batch': batch, - 'location': location.pk if location else None - } + 'location': location.pk if location else None, + }, ) if self.status == BuildStatus.PENDING: @@ -966,13 +974,13 @@ class Build( - Delete the output StockItem """ if not output: - raise ValidationError(_("No build output specified")) + raise ValidationError(_('No build output specified')) if not output.is_building: - raise ValidationError(_("Build output is already completed")) + raise ValidationError(_('Build output is already completed')) if output.build != self: - raise ValidationError(_("Build output does not match Build Order")) + raise ValidationError(_('Build output does not match Build Order')) # Deallocate all build items against the output self.deallocate_stock(output=output) @@ -993,7 +1001,6 @@ class Build( lines = annotate_allocated_quantity(lines) for build_line in lines: - reduce_by = build_line.allocated - build_line.quantity if reduce_by <= 0: @@ -1001,7 +1008,6 @@ class Build( # Find BuildItem objects to trim for item in BuildItem.objects.filter(build_line=build_line): - # Previous item completed the job if reduce_by <= 0: break @@ -1025,10 +1031,8 @@ class Build( @property def allocated_stock(self): - """Returns a QuerySet object of all BuildItem objects which point back to this Build""" - return BuildItem.objects.filter( - build_line__build=self - ) + """Returns a QuerySet object of all BuildItem objects which point back to this Build.""" + return BuildItem.objects.filter(build_line__build=self) @transaction.atomic def subtract_allocated_stock(self, user): @@ -1047,7 +1051,7 @@ class Build( @transaction.atomic def scrap_build_output(self, output, quantity, location, **kwargs): - """Mark a particular build output as scrapped / rejected + """Mark a particular build output as scrapped / rejected. - Mark the output as "complete" - *Do Not* update the "completed" count for this order @@ -1055,19 +1059,17 @@ class Build( - Add a transaction entry to the stock item history """ if not output: - raise ValidationError(_("No build output specified")) + raise ValidationError(_('No build output specified')) if quantity <= 0: - raise ValidationError({ - 'quantity': _("Quantity must be greater than zero") - }) + raise ValidationError({'quantity': _('Quantity must be greater than zero')}) if quantity > output.quantity: raise ValidationError({ - 'quantity': _("Quantity cannot be greater than the output quantity") + 'quantity': _('Quantity cannot be greater than the output quantity') }) - user = kwargs.get('user', None) + user = kwargs.get('user') notes = kwargs.get('notes', '') discard_allocations = kwargs.get('discard_allocations', False) @@ -1100,7 +1102,7 @@ class Build( 'location': location.pk, 'status': StockStatus.REJECTED.value, 'buildorder': self.pk, - } + }, ) @transaction.atomic @@ -1119,12 +1121,18 @@ class Build( allocated_items = output.items_to_install.all() required_tests = kwargs.get('required_tests', output.part.getRequiredTests()) - prevent_on_incomplete = kwargs.get('prevent_on_incomplete', common.settings.prevent_build_output_complete_on_incompleted_tests()) + prevent_on_incomplete = kwargs.get( + 'prevent_on_incomplete', + prevent_build_output_complete_on_incompleted_tests(), + ) - if (prevent_on_incomplete and not output.passedAllRequiredTests(required_tests=required_tests)): + if prevent_on_incomplete and not output.passedAllRequiredTests( + required_tests=required_tests + ): serial = output.serial raise ValidationError( - _(f"Build output {serial} has not passed all required tests")) + _(f'Build output {serial} has not passed all required tests') + ) for build_item in allocated_items: # Complete the allocation of stock for that item @@ -1141,26 +1149,16 @@ class Build( output.save(add_note=False) - deltas = { - 'status': status, - 'buildorder': self.pk - } + deltas = {'status': status, 'buildorder': self.pk} if location: deltas['location'] = location.pk output.add_tracking_entry( - StockHistoryCode.BUILD_OUTPUT_COMPLETED, - user, - notes=notes, - deltas=deltas + StockHistoryCode.BUILD_OUTPUT_COMPLETED, user, notes=notes, deltas=deltas ) - trigger_event( - BuildEvents.OUTPUT_COMPLETED, - id=output.pk, - build_id=self.pk, - ) + trigger_event(BuildEvents.OUTPUT_COMPLETED, id=output.pk, build_id=self.pk) # Increase the completed quantity for this build self.completed += output.quantity @@ -1181,8 +1179,8 @@ class Build( - If multiple stock items are found, we *may* be able to allocate: - If the calling function has specified that items are interchangeable """ - location = kwargs.get('location', None) - exclude_location = kwargs.get('exclude_location', None) + location = kwargs.get('location') + exclude_location = kwargs.get('exclude_location') interchangeable = kwargs.get('interchangeable', False) substitutes = kwargs.get('substitutes', True) optional_items = kwargs.get('optional_items', False) @@ -1198,7 +1196,6 @@ class Build( # Auto-allocation is only possible for "untracked" line items for line_item in self.untracked_line_items.all(): - # Find the referenced BomItem bom_item = line_item.bom_item @@ -1220,30 +1217,35 @@ class Build( # Check which parts we can "use" (may include variants and substitutes) available_parts = bom_item.get_valid_parts_for_allocation( - allow_variants=True, - allow_substitutes=substitutes, + allow_variants=True, allow_substitutes=substitutes ) # Look for available stock items - available_stock = stock.models.StockItem.objects.filter(stock.models.StockItem.IN_STOCK_FILTER) - - # Filter by list of available parts - available_stock = available_stock.filter( - part__in=list(available_parts), + available_stock = stock.models.StockItem.objects.filter( + stock.models.StockItem.IN_STOCK_FILTER ) + # Filter by list of available parts + available_stock = available_stock.filter(part__in=list(available_parts)) + # Filter out "serialized" stock items, these cannot be auto-allocated - available_stock = available_stock.filter(Q(serial=None) | Q(serial='')).distinct() + available_stock = available_stock.filter( + Q(serial=None) | Q(serial='') + ).distinct() if location: # Filter only stock items located "below" the specified location sublocations = location.get_descendants(include_self=True) - available_stock = available_stock.filter(location__in=list(sublocations)) + available_stock = available_stock.filter( + location__in=list(sublocations) + ) if exclude_location: # Exclude any stock items from the provided location sublocations = exclude_location.get_descendants(include_self=True) - available_stock = available_stock.exclude(location__in=list(sublocations)) + available_stock = available_stock.exclude( + location__in=list(sublocations) + ) """ Next, we sort the available stock items with the following priority: @@ -1253,7 +1255,10 @@ class Build( This ensures that allocation priority is first given to "direct" parts """ - available_stock = sorted(available_stock, key=lambda item, b=bom_item, v=variant_parts: stock_sort(item, b, v)) + available_stock = sorted( + available_stock, + key=lambda item, b=bom_item, v=variant_parts: stock_sort(item, b, v), + ) if len(available_stock) == 0: # No stock items are available @@ -1263,29 +1268,33 @@ class Build( # or all items are "interchangeable" and we don't care where we take stock from for stock_item in available_stock: - # Skip inactive parts if not stock_item.part.active: continue # How much of the stock item is "available" for allocation? - quantity = min(unallocated_quantity, stock_item.unallocated_quantity()) + quantity = min( + unallocated_quantity, stock_item.unallocated_quantity() + ) if quantity > 0: - try: - new_items.append(BuildItem( - build_line=line_item, - stock_item=stock_item, - quantity=quantity, - )) + new_items.append( + BuildItem( + build_line=line_item, + stock_item=stock_item, + quantity=quantity, + ) + ) # Subtract the required quantity unallocated_quantity -= quantity except (ValidationError, serializers.ValidationError) as exc: # Catch model errors and re-throw as DRF errors - raise ValidationError(detail=serializers.as_serializer_error(exc)) + raise ValidationError( + detail=serializers.as_serializer_error(exc) + ) if unallocated_quantity <= 0: # We have now fully-allocated this BomItem - no need to continue! @@ -1324,11 +1333,10 @@ class Build( Returns: True if the BuildOrder has been fully allocated, otherwise False """ - return self.unallocated_lines(tracked=tracked).count() == 0 def is_output_fully_allocated(self, output): - """Determine if the specified output (StockItem) has been fully allocated for this build + """Determine if the specified output (StockItem) has been fully allocated for this build. Args: output: StockItem object (the "in production" output to test against) @@ -1336,17 +1344,13 @@ class Build( To determine if the output has been fully allocated, we need to test all "trackable" BuildLine objects """ - lines = self.build_lines.filter(bom_item__sub_part__trackable=True) lines = lines.exclude(bom_item__consumable=True) # Find any lines which have not been fully allocated for line in lines: # Grab all BuildItem objects which point to this output - allocations = BuildItem.objects.filter( - build_line=line, - install_into=output, - ) + allocations = BuildItem.objects.filter(build_line=line, install_into=output) allocated = allocations.aggregate( q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField()) @@ -1365,7 +1369,6 @@ class Build( Returns: True if any BuildLine has been over-allocated. """ - lines = self.build_lines.all().exclude(bom_item__consumable=True) lines = annotate_allocated_quantity(lines) @@ -1396,34 +1399,36 @@ class Build( bom_items = self.part.get_bom_items() - logger.info("Creating BuildLine objects for BuildOrder %s (%s items)", self.pk, len(bom_items)) + logger.info( + 'Creating BuildLine objects for BuildOrder %s (%s items)', + self.pk, + len(bom_items), + ) # Iterate through each part required to build the parent part for bom_item in bom_items: if prevent_duplicates: if BuildLine.objects.filter(build=self, bom_item=bom_item).exists(): - logger.info("BuildLine already exists for BuildOrder %s and BomItem %s", self.pk, bom_item.pk) + logger.info( + 'BuildLine already exists for BuildOrder %s and BomItem %s', + self.pk, + bom_item.pk, + ) continue # Calculate required quantity quantity = bom_item.get_required_quantity(self.quantity) - lines.append( - BuildLine( - build=self, - bom_item=bom_item, - quantity=quantity - ) - ) + lines.append(BuildLine(build=self, bom_item=bom_item, quantity=quantity)) BuildLine.objects.bulk_create(lines) if len(lines) > 0: - logger.info("Created %s BuildLine objects for BuildOrder", len(lines)) + logger.info('Created %s BuildLine objects for BuildOrder', len(lines)) @transaction.atomic def update_build_line_items(self): - """Rebuild required quantity field for each BuildLine object""" + """Rebuild required quantity field for each BuildLine object.""" lines_to_update = [] for line in self.build_lines.all(): @@ -1432,20 +1437,21 @@ class Build( BuildLine.objects.bulk_update(lines_to_update, ['quantity']) - logger.info("Updated %s BuildLine objects for BuildOrder", len(lines_to_update)) + logger.info('Updated %s BuildLine objects for BuildOrder', len(lines_to_update)) @receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') def after_save_build(sender, instance: Build, created: bool, **kwargs): """Callback function to be executed after a Build instance is saved.""" # Escape if we are importing data - if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True): + if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase( + allow_test=True + ): return from . import tasks as build_tasks if instance: - if created: # A new Build has just been created @@ -1454,13 +1460,13 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs): # Run checks on required parts InvenTree.tasks.offload_task( - build_tasks.check_build_stock, - instance, - group='build' + build_tasks.check_build_stock, instance, group='build' ) # Notify the responsible users that the build order has been created - InvenTree.helpers_model.notify_responsible(instance, sender, exclude=instance.issued_by) + InvenTree.helpers_model.notify_responsible( + instance, sender, exclude=instance.issued_by + ) else: # Update BuildLine objects if the Build quantity has changed @@ -1485,19 +1491,17 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo class Meta: """Model meta options.""" + verbose_name = _('Build Order Line Item') - unique_together = [ - ('build', 'bom_item'), - ] + unique_together = [('build', 'bom_item')] @staticmethod def get_api_url(): - """Return the API URL used to access this model""" + """Return the API URL used to access this model.""" return reverse('api-build-line-list') def report_context(self): """Generate custom report context for this BuildLine object.""" - return { 'allocated_quantity': self.allocated_quantity, 'allocations': self.allocations, @@ -1509,14 +1513,14 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo } build = models.ForeignKey( - Build, on_delete=models.CASCADE, - related_name='build_lines', help_text=_('Build object') + Build, + on_delete=models.CASCADE, + related_name='build_lines', + help_text=_('Build object'), ) bom_item = models.ForeignKey( - part.models.BomItem, - on_delete=models.CASCADE, - related_name='build_lines', + part.models.BomItem, on_delete=models.CASCADE, related_name='build_lines' ) quantity = models.DecimalField( @@ -1530,11 +1534,11 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo @property def part(self): - """Return the sub_part reference from the link bom_item""" + """Return the sub_part reference from the link bom_item.""" return self.bom_item.sub_part def allocated_quantity(self): - """Calculate the total allocated quantity for this BuildLine""" + """Calculate the total allocated quantity for this BuildLine.""" # Queryset containing all BuildItem objects allocated against this BuildLine allocations = self.allocations.all() @@ -1545,18 +1549,18 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo return allocated['q'] def unallocated_quantity(self): - """Return the unallocated quantity for this BuildLine""" + """Return the unallocated quantity for this BuildLine.""" return max(self.quantity - self.allocated_quantity(), 0) def is_fully_allocated(self): - """Return True if this BuildLine is fully allocated""" + """Return True if this BuildLine is fully allocated.""" if self.bom_item.consumable: return True return self.allocated_quantity() >= self.quantity def is_overallocated(self): - """Return True if this BuildLine is over-allocated""" + """Return True if this BuildLine is over-allocated.""" return self.allocated_quantity() > self.quantity @@ -1575,17 +1579,16 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): class Meta: """Model meta options.""" - unique_together = [ - ('build_line', 'stock_item', 'install_into'), - ] + + unique_together = [('build_line', 'stock_item', 'install_into')] @staticmethod def get_api_url(): - """Return the API URL used to access this model""" + """Return the API URL used to access this model.""" return reverse('api-build-item-list') def save(self, *args, **kwargs): - """Custom save method for the BuildItem model""" + """Custom save method for the BuildItem model.""" self.clean() super().save() @@ -1602,42 +1605,52 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): super().clean() try: - # If the 'part' is trackable, then the 'install_into' field must be set! - if self.stock_item.part and self.stock_item.part.trackable and not self.install_into: - raise ValidationError(_('Build item must specify a build output, as master part is marked as trackable')) + if ( + self.stock_item.part + and self.stock_item.part.trackable + and not self.install_into + ): + raise ValidationError( + _( + 'Build item must specify a build output, as master part is marked as trackable' + ) + ) # Allocated quantity cannot exceed available stock quantity if self.quantity > self.stock_item.quantity: - q = InvenTree.helpers.normalize(self.quantity) a = InvenTree.helpers.normalize(self.stock_item.quantity) raise ValidationError({ - 'quantity': _(f'Allocated quantity ({q}) must not exceed available stock quantity ({a})') + 'quantity': _( + f'Allocated quantity ({q}) must not exceed available stock quantity ({a})' + ) }) # Ensure that we do not 'over allocate' a stock item available = decimal.Decimal(self.stock_item.quantity) quantity = decimal.Decimal(self.quantity) - build_allocation_count = decimal.Decimal(self.stock_item.build_allocation_count( - exclude_allocations={'pk': self.pk} - )) - sales_allocation_count = decimal.Decimal(self.stock_item.sales_order_allocation_count()) + build_allocation_count = decimal.Decimal( + self.stock_item.build_allocation_count( + exclude_allocations={'pk': self.pk} + ) + ) + sales_allocation_count = decimal.Decimal( + self.stock_item.sales_order_allocation_count() + ) total_allocation = ( build_allocation_count + sales_allocation_count + quantity ) if total_allocation > available: - raise ValidationError({ - 'quantity': _('Stock item is over-allocated') - }) + raise ValidationError({'quantity': _('Stock item is over-allocated')}) # Allocated quantity must be positive if self.quantity <= 0: raise ValidationError({ - 'quantity': _('Allocation quantity must be greater than zero'), + 'quantity': _('Allocation quantity must be greater than zero') }) # Quantity must be 1 for serialized stock @@ -1647,9 +1660,9 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): }) except stock.models.StockItem.DoesNotExist: - raise ValidationError("Stock item must be specified") + raise ValidationError('Stock item must be specified') except part.models.Part.DoesNotExist: - raise ValidationError("Part must be specified") + raise ValidationError('Part must be specified') """ Attempt to find the "BomItem" which links this BuildItem to the build. @@ -1675,18 +1688,20 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): valid = self.bom_item.is_stock_item_valid(self.stock_item) elif self.bom_item.inherited: - if self.build.part in self.bom_item.part.get_descendants(include_self=False): + if self.build.part in self.bom_item.part.get_descendants( + include_self=False + ): valid = self.bom_item.is_stock_item_valid(self.stock_item) # If the existing BomItem is *not* valid, try to find a match if not valid and self.build and self.stock_item: - ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True) + ancestors = self.stock_item.part.get_ancestors( + include_self=True, ascending=True + ) for idx, ancestor in enumerate(ancestors): - build_line = BuildLine.objects.filter( - build=self.build, - bom_item__part=ancestor, + build=self.build, bom_item__part=ancestor ) if build_line.exists(): @@ -1700,19 +1715,18 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): # BomItem did not exist or could not be validated. # Search for a new one if not valid: - raise ValidationError({ - 'stock_item': _("Selected stock item does not match BOM line") + 'stock_item': _('Selected stock item does not match BOM line') }) @property def build(self): - """Return the BuildOrder associated with this BuildItem""" + """Return the BuildOrder associated with this BuildItem.""" return self.build_line.build if self.build_line else None @property def bom_item(self): - """Return the BomItem associated with this BuildItem""" + """Return the BomItem associated with this BuildItem.""" return self.build_line.bom_item if self.build_line else None @transaction.atomic @@ -1729,27 +1743,17 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): # Split the allocated stock if there are more available than allocated if item.quantity > self.quantity: - item = item.splitStock( - self.quantity, - None, - user, - notes=notes, - ) + item = item.splitStock(self.quantity, None, user, notes=notes) # For a trackable part, special consideration needed! if item.part.trackable: - # Make sure we are pointing to the new item self.stock_item = item self.save() # Install the stock item into the output self.install_into.installStockItem( - item, - self.quantity, - user, - notes, - build=self.build, + item, self.quantity, user, notes, build=self.build ) else: @@ -1762,16 +1766,11 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): StockHistoryCode.BUILD_CONSUMED, user, notes=notes, - deltas={ - 'buildorder': self.build.pk, - 'quantity': float(item.quantity), - } + deltas={'buildorder': self.build.pk, 'quantity': float(item.quantity)}, ) build_line = models.ForeignKey( - BuildLine, - on_delete=models.CASCADE, null=True, - related_name='allocations', + BuildLine, on_delete=models.CASCADE, null=True, related_name='allocations' ) stock_item = models.ForeignKey( @@ -1780,10 +1779,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): related_name='allocations', verbose_name=_('Stock Item'), help_text=_('Source stock item'), - limit_choices_to={ - 'sales_order': None, - 'belongs_to': None, - } + limit_choices_to={'sales_order': None, 'belongs_to': None}, ) quantity = models.DecimalField( @@ -1792,17 +1788,16 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): default=1, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), - help_text=_('Stock quantity to allocate to build') + help_text=_('Stock quantity to allocate to build'), ) install_into = models.ForeignKey( 'stock.StockItem', on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, related_name='items_to_install', verbose_name=_('Install into'), help_text=_('Destination stock item'), - limit_choices_to={ - 'is_building': True, - } + limit_choices_to={'is_building': True}, ) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 845cd584bd..1e10c0db96 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -7,7 +7,6 @@ from django.db import models, transaction from django.db.models import ( BooleanField, Case, - Count, ExpressionWrapper, F, FloatField, @@ -49,11 +48,17 @@ from .models import Build, BuildItem, BuildLine from .status_codes import BuildStatus -class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeCustomStatusSerializerMixin, InvenTreeModelSerializer): +class BuildSerializer( + NotesFieldMixin, + DataImportExportSerializerMixin, + InvenTreeCustomStatusSerializerMixin, + InvenTreeModelSerializer, +): """Serializes a Build object.""" class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" + model = Build fields = [ 'pk', @@ -89,7 +94,6 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre 'responsible_detail', 'priority', 'level', - # Additional fields used only for build order creation 'create_child_builds', ] @@ -111,9 +115,13 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre status_text = serializers.CharField(source='get_status_display', read_only=True) - part_detail = part_serializers.PartBriefSerializer(source='part', many=False, read_only=True) + part_detail = part_serializers.PartBriefSerializer( + source='part', many=False, read_only=True + ) - part_name = serializers.CharField(source='part.name', read_only=True, label=_('Part Name')) + part_name = serializers.CharField( + source='part.name', read_only=True, label=_('Part Name') + ) quantity = InvenTreeDecimalField() @@ -125,12 +133,18 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre barcode_hash = serializers.CharField(read_only=True) - project_code_label = serializers.CharField(source='project_code.code', read_only=True, label=_('Project Code Label')) + project_code_label = serializers.CharField( + source='project_code.code', read_only=True, label=_('Project Code Label') + ) - project_code_detail = ProjectCodeSerializer(source='project_code', many=False, read_only=True) + project_code_detail = ProjectCodeSerializer( + source='project_code', many=False, read_only=True + ) create_child_builds = serializers.BooleanField( - default=False, required=False, write_only=True, + default=False, + required=False, + write_only=True, label=_('Create Child Builds'), help_text=_('Automatically generate child build orders'), ) @@ -148,16 +162,16 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre queryset = queryset.annotate( overdue=Case( When( - Build.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), + Build.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()) ), - default=Value(False, output_field=BooleanField()) + default=Value(False, output_field=BooleanField()), ) ) return queryset def __init__(self, *args, **kwargs): - """Determine if extra serializer fields are required""" + """Determine if extra serializer fields are required.""" part_detail = kwargs.pop('part_detail', True) create = kwargs.pop('create', False) @@ -174,7 +188,7 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre return ['create_child_builds'] def validate_reference(self, reference): - """Custom validation for the Build reference field""" + """Custom validation for the Build reference field.""" # Ensure the reference matches the required pattern Build.validate_reference_field(reference) @@ -183,7 +197,6 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre @transaction.atomic def create(self, validated_data): """Save the Build object.""" - build_order = super().create(validated_data) create_child_builds = self.validated_data.pop('create_child_builds', False) @@ -191,9 +204,7 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre if create_child_builds: # Pass child build creation off to the background thread InvenTree.tasks.offload_task( - build.tasks.create_child_builds, - build_order.pk, - group='build' + build.tasks.create_child_builds, build_order.pk, group='build' ) return build_order @@ -206,10 +217,9 @@ class BuildOutputSerializer(serializers.Serializer): """ class Meta: - """Serializer metaclass""" - fields = [ - 'output', - ] + """Serializer metaclass.""" + + fields = ['output'] output = serializers.PrimaryKeyRelatedField( queryset=StockItem.objects.all(), @@ -220,7 +230,7 @@ class BuildOutputSerializer(serializers.Serializer): ) def validate_output(self, output): - """Perform validation for the output (StockItem) provided to the serializer""" + """Perform validation for the output (StockItem) provided to the serializer.""" build = self.context['build'] # As this serializer can be used in multiple contexts, we need to work out why we are here @@ -228,38 +238,39 @@ class BuildOutputSerializer(serializers.Serializer): # The stock item must point to the build if output.build != build: - raise ValidationError(_("Build output does not match the parent build")) + raise ValidationError(_('Build output does not match the parent build')) # The part must match! if output.part != build.part: - raise ValidationError(_("Output part does not match BuildOrder part")) + raise ValidationError(_('Output part does not match BuildOrder part')) # The build output must be "in production" if not output.is_building: - raise ValidationError(_("This build output has already been completed")) + raise ValidationError(_('This build output has already been completed')) if to_complete: - # The build output must have all tracked parts allocated if not build.is_output_fully_allocated(output): - # Check if the user has specified that incomplete allocations are ok - accept_incomplete = InvenTree.helpers.str2bool(self.context['request'].data.get('accept_incomplete_allocation', False)) + accept_incomplete = InvenTree.helpers.str2bool( + self.context['request'].data.get( + 'accept_incomplete_allocation', False + ) + ) if not accept_incomplete: - raise ValidationError(_("This build output is not fully allocated")) + raise ValidationError(_('This build output is not fully allocated')) return output class BuildOutputQuantitySerializer(BuildOutputSerializer): - """Serializer for a single build output, with additional quantity field""" + """Build output with quantity field.""" class Meta: - """Serializer metaclass""" - fields = BuildOutputSerializer.Meta.fields + [ - 'quantity', - ] + """Serializer metaclass.""" + + fields = [*BuildOutputSerializer.Meta.fields, 'quantity'] quantity = serializers.DecimalField( max_digits=15, @@ -271,20 +282,18 @@ class BuildOutputQuantitySerializer(BuildOutputSerializer): ) def validate(self, data): - """Validate the serializer data""" + """Validate the serializer data.""" data = super().validate(data) output = data.get('output') quantity = data.get('quantity') if quantity <= 0: - raise ValidationError({ - 'quantity': _('Quantity must be greater than zero') - }) + raise ValidationError({'quantity': _('Quantity must be greater than zero')}) if quantity > output.quantity: raise ValidationError({ - 'quantity': _("Quantity cannot be greater than the output quantity") + 'quantity': _('Quantity cannot be greater than the output quantity') }) return data @@ -300,6 +309,7 @@ class BuildOutputCreateSerializer(serializers.Serializer): class Meta: """Serializer metaclass.""" + fields = [ 'quantity', 'batch_code', @@ -318,27 +328,33 @@ class BuildOutputCreateSerializer(serializers.Serializer): ) def get_build(self): - """Return the Build instance associated with this serializer""" - return self.context["build"] + """Return the Build instance associated with this serializer.""" + return self.context['build'] def get_part(self): - """Return the Part instance associated with the build""" + """Return the Part instance associated with the build.""" return self.get_build().part def validate_quantity(self, quantity): - """Validate the provided quantity field""" + """Validate the provided quantity field.""" if quantity <= 0: - raise ValidationError(_("Quantity must be greater than zero")) + raise ValidationError(_('Quantity must be greater than zero')) part = self.get_part() if int(quantity) != quantity: # Quantity must be an integer value if the part being built is trackable if part.trackable: - raise ValidationError(_("Integer quantity required for trackable parts")) + raise ValidationError( + _('Integer quantity required for trackable parts') + ) if part.has_trackable_parts: - raise ValidationError(_("Integer quantity required, as the bill of materials contains trackable parts")) + raise ValidationError( + _( + 'Integer quantity required, as the bill of materials contains trackable parts' + ) + ) return quantity @@ -361,11 +377,12 @@ class BuildOutputCreateSerializer(serializers.Serializer): queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Stock location for build output'), - required=False, allow_null=True + required=False, + allow_null=True, ) def validate_serial_numbers(self, serial_numbers): - """Clean the provided serial number string""" + """Clean the provided serial number string.""" serial_numbers = serial_numbers.strip() return serial_numbers @@ -375,7 +392,9 @@ class BuildOutputCreateSerializer(serializers.Serializer): default=False, allow_null=True, label=_('Auto Allocate Serial Numbers'), - help_text=_('Automatically allocate required items with matching serial numbers'), + help_text=_( + 'Automatically allocate required items with matching serial numbers' + ), ) def validate(self, data): @@ -390,40 +409,33 @@ class BuildOutputCreateSerializer(serializers.Serializer): if part.trackable and not serial_numbers: raise ValidationError({ - 'serial_numbers': _('Serial numbers must be provided for trackable parts') + 'serial_numbers': _( + 'Serial numbers must be provided for trackable parts' + ) }) if serial_numbers: - try: self.serials = InvenTree.helpers.extract_serial_numbers( - serial_numbers, - quantity, - part.get_latest_serial_number(), - part=part + serial_numbers, quantity, part.get_latest_serial_number(), part=part ) except DjangoValidationError as e: - raise ValidationError({ - 'serial_numbers': e.messages, - }) + raise ValidationError({'serial_numbers': e.messages}) # Check for conflicting serial numbesr existing = part.find_conflicting_serial_numbers(self.serials) if len(existing) > 0: + msg = _('The following serial numbers already exist or are invalid') + msg += ' : ' + msg += ','.join([str(e) for e in existing]) - msg = _("The following serial numbers already exist or are invalid") - msg += " : " - msg += ",".join([str(e) for e in existing]) - - raise ValidationError({ - 'serial_numbers': msg, - }) + raise ValidationError({'serial_numbers': msg}) return data def save(self): - """Generate the new build output(s)""" + """Generate the new build output(s).""" data = self.validated_data build = self.get_build() @@ -442,24 +454,20 @@ class BuildOutputDeleteSerializer(serializers.Serializer): """DRF serializer for deleting (cancelling) one or more build outputs.""" class Meta: - """Serializer metaclass""" - fields = [ - 'outputs', - ] + """Serializer metaclass.""" - outputs = BuildOutputSerializer( - many=True, - required=True, - ) + fields = ['outputs'] + + outputs = BuildOutputSerializer(many=True, required=True) def validate(self, data): - """Perform data validation for this serializer""" + """Perform data validation for this serializer.""" data = super().validate(data) outputs = data.get('outputs', []) if len(outputs) == 0: - raise ValidationError(_("A list of build outputs must be provided")) + raise ValidationError(_('A list of build outputs must be provided')) return data @@ -477,20 +485,14 @@ class BuildOutputDeleteSerializer(serializers.Serializer): class BuildOutputScrapSerializer(serializers.Serializer): - """DRF serializer for scrapping one or more build outputs""" + """Scrapping one or more build outputs.""" class Meta: - """Serializer metaclass""" - fields = [ - 'outputs', - 'location', - 'notes', - ] + """Serializer metaclass.""" - outputs = BuildOutputQuantitySerializer( - many=True, - required=True, - ) + fields = ['outputs', 'location', 'notes'] + + outputs = BuildOutputQuantitySerializer(many=True, required=True) location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), @@ -516,17 +518,17 @@ class BuildOutputScrapSerializer(serializers.Serializer): ) def validate(self, data): - """Perform validation on the serializer data""" + """Perform validation on the serializer data.""" super().validate(data) outputs = data.get('outputs', []) if len(outputs) == 0: - raise ValidationError(_("A list of build outputs must be provided")) + raise ValidationError(_('A list of build outputs must be provided')) return data def save(self): - """Save the serializer to scrap the build outputs""" + """Save the serializer to scrap the build outputs.""" build = self.context['build'] request = self.context['request'] data = self.validated_data @@ -543,7 +545,7 @@ class BuildOutputScrapSerializer(serializers.Serializer): data.get('location', None), user=request.user, notes=data.get('notes', ''), - discard_allocations=data.get('discard_allocations', False) + discard_allocations=data.get('discard_allocations', False), ) @@ -551,7 +553,8 @@ class BuildOutputCompleteSerializer(serializers.Serializer): """DRF serializer for completing one or more build outputs.""" class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" + fields = [ 'outputs', 'location', @@ -560,23 +563,18 @@ class BuildOutputCompleteSerializer(serializers.Serializer): 'notes', ] - outputs = BuildOutputSerializer( - many=True, - required=True, - ) + outputs = BuildOutputSerializer(many=True, required=True) location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), required=True, many=False, - label=_("Location"), - help_text=_("Location for completed build outputs"), + label=_('Location'), + help_text=_('Location for completed build outputs'), ) status_custom_key = serializers.ChoiceField( - choices=StockStatus.items(), - default=StockStatus.OK.value, - label=_("Status"), + choices=StockStatus.items(), default=StockStatus.OK.value, label=_('Status') ) accept_incomplete_allocation = serializers.BooleanField( @@ -586,14 +584,10 @@ class BuildOutputCompleteSerializer(serializers.Serializer): help_text=_('Complete outputs if stock has not been fully allocated'), ) - notes = serializers.CharField( - label=_("Notes"), - required=False, - allow_blank=True, - ) + notes = serializers.CharField(label=_('Notes'), required=False, allow_blank=True) def validate(self, data): - """Perform data validation for this serializer""" + """Perform data validation for this serializer.""" super().validate(data) outputs = data.get('outputs', []) @@ -602,15 +596,20 @@ class BuildOutputCompleteSerializer(serializers.Serializer): errors = [] for output in outputs: stock_item = output['output'] - if stock_item.hasRequiredTests() and not stock_item.passedAllRequiredTests(): + if ( + stock_item.hasRequiredTests() + and not stock_item.passedAllRequiredTests() + ): serial = stock_item.serial - errors.append(_(f"Build output {serial} has not passed all required tests")) + errors.append( + _(f'Build output {serial} has not passed all required tests') + ) if errors: raise ValidationError(errors) if len(outputs) == 0: - raise ValidationError(_("A list of build outputs must be provided")) + raise ValidationError(_('A list of build outputs must be provided')) return data @@ -629,7 +628,9 @@ class BuildOutputCompleteSerializer(serializers.Serializer): # Cache some calculated values which can be passed to each output required_tests = outputs[0]['output'].part.getRequiredTests() - prevent_on_incomplete = common.settings.prevent_build_output_complete_on_incompleted_tests() + prevent_on_incomplete = ( + common.settings.prevent_build_output_complete_on_incompleted_tests() + ) # Mark the specified build outputs as "complete" with transaction.atomic(): @@ -651,11 +652,12 @@ class BuildIssueSerializer(serializers.Serializer): """DRF serializer for issuing a build order.""" class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" + fields = [] def save(self): - """Issue the specified build order""" + """Issue the specified build order.""" build = self.context['build'] build.issue_build() @@ -665,6 +667,7 @@ class BuildHoldSerializer(serializers.Serializer): class Meta: """Serializer metaclass.""" + fields = [] def save(self): @@ -675,17 +678,15 @@ class BuildHoldSerializer(serializers.Serializer): class BuildCancelSerializer(serializers.Serializer): - """DRF serializer class for cancelling an active BuildOrder""" + """Cancel an active BuildOrder.""" class Meta: - """Serializer metaclass""" - fields = [ - 'remove_allocated_stock', - 'remove_incomplete_outputs', - ] + """Serializer metaclass.""" + + fields = ['remove_allocated_stock', 'remove_incomplete_outputs'] def get_context_data(self): - """Retrieve extra context data from this serializer""" + """Retrieve extra context data from this serializer.""" build = self.context['build'] return { @@ -709,7 +710,7 @@ class BuildCancelSerializer(serializers.Serializer): ) def save(self): - """Cancel the specified build""" + """Cancel the specified build.""" build = self.context['build'] request = self.context['request'] @@ -722,7 +723,7 @@ class BuildCancelSerializer(serializers.Serializer): ) -class OverallocationChoice(): +class OverallocationChoice: """Utility class to contain options for handling over allocated stock items.""" REJECT = 'reject' @@ -740,12 +741,9 @@ class BuildCompleteSerializer(serializers.Serializer): """DRF serializer for marking a BuildOrder as complete.""" class Meta: - """Serializer metaclass""" - fields = [ - 'accept_overallocated', - 'accept_unallocated', - 'accept_incomplete', - ] + """Serializer metaclass.""" + + fields = ['accept_overallocated', 'accept_unallocated', 'accept_incomplete'] def get_context_data(self): """Retrieve extra context data for this serializer. @@ -764,13 +762,15 @@ class BuildCompleteSerializer(serializers.Serializer): accept_overallocated = serializers.ChoiceField( label=_('Overallocated Stock'), choices=list(OverallocationChoice.OPTIONS.items()), - help_text=_('How do you want to handle extra stock items assigned to the build order'), + help_text=_( + 'How do you want to handle extra stock items assigned to the build order' + ), required=False, default=OverallocationChoice.REJECT, ) def validate_accept_overallocated(self, value): - """Check if the 'accept_overallocated' field is required""" + """Check if the 'accept_overallocated' field is required.""" build = self.context['build'] if build.is_overallocated() and value == OverallocationChoice.REJECT: @@ -780,13 +780,15 @@ class BuildCompleteSerializer(serializers.Serializer): accept_unallocated = serializers.BooleanField( label=_('Accept Unallocated'), - help_text=_('Accept that stock items have not been fully allocated to this build order'), + help_text=_( + 'Accept that stock items have not been fully allocated to this build order' + ), required=False, default=False, ) def validate_accept_unallocated(self, value): - """Check if the 'accept_unallocated' field is required""" + """Check if the 'accept_unallocated' field is required.""" build = self.context['build'] if not build.are_untracked_parts_allocated and not value: @@ -796,13 +798,15 @@ class BuildCompleteSerializer(serializers.Serializer): accept_incomplete = serializers.BooleanField( label=_('Accept Incomplete'), - help_text=_('Accept that the required number of build outputs have not been completed'), + help_text=_( + 'Accept that the required number of build outputs have not been completed' + ), required=False, default=False, ) def validate_accept_incomplete(self, value): - """Check if the 'accept_incomplete' field is required""" + """Check if the 'accept_incomplete' field is required.""" build = self.context['build'] if build.remaining > 0 and not value: @@ -811,22 +815,25 @@ class BuildCompleteSerializer(serializers.Serializer): return value def validate(self, data): - """Perform validation of this serializer prior to saving""" + """Perform validation of this serializer prior to saving.""" build = self.context['build'] - if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and build.has_open_child_builds: - raise ValidationError(_("Build order has open child build orders")) + if ( + get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') + and build.has_open_child_builds + ): + raise ValidationError(_('Build order has open child build orders')) if build.status != BuildStatus.PRODUCTION.value: - raise ValidationError(_("Build order must be in production state")) + raise ValidationError(_('Build order must be in production state')) if build.incomplete_count > 0: - raise ValidationError(_("Build order has incomplete outputs")) + raise ValidationError(_('Build order has incomplete outputs')) return data def save(self): - """Complete the specified build output""" + """Complete the specified build output.""" request = self.context['request'] build = self.context['build'] @@ -834,7 +841,10 @@ class BuildCompleteSerializer(serializers.Serializer): build.complete_build( request.user, - trim_allocated_stock=data.get('accept_overallocated', OverallocationChoice.REJECT) == OverallocationChoice.TRIM + trim_allocated_stock=data.get( + 'accept_overallocated', OverallocationChoice.REJECT + ) + == OverallocationChoice.TRIM, ) @@ -848,11 +858,9 @@ class BuildUnallocationSerializer(serializers.Serializer): """ class Meta: - """Serializer metaclass""" - fields = [ - 'build_line', - 'output', - ] + """Serializer metaclass.""" + + fields = ['build_line', 'output'] build_line = serializers.PrimaryKeyRelatedField( queryset=BuildLine.objects.all(), @@ -863,13 +871,11 @@ class BuildUnallocationSerializer(serializers.Serializer): ) output = serializers.PrimaryKeyRelatedField( - queryset=StockItem.objects.filter( - is_building=True, - ), + queryset=StockItem.objects.filter(is_building=True), many=False, allow_null=True, required=False, - label=_("Build output"), + label=_('Build output'), ) def validate_output(self, stock_item): @@ -877,7 +883,7 @@ class BuildUnallocationSerializer(serializers.Serializer): build = self.context['build'] if stock_item and stock_item.build != build: - raise ValidationError(_("Build output must point to the same build")) + raise ValidationError(_('Build output must point to the same build')) return stock_item @@ -891,8 +897,7 @@ class BuildUnallocationSerializer(serializers.Serializer): data = self.validated_data build.deallocate_stock( - build_line=data.get('build_line', None), - output=data.get('output', None), + build_line=data.get('build_line', None), output=data.get('output', None) ) @@ -900,13 +905,9 @@ class BuildAllocationItemSerializer(serializers.Serializer): """A serializer for allocating a single stock item against a build order.""" class Meta: - """Serializer metaclass""" - fields = [ - 'build_item', - 'stock_item', - 'quantity', - 'output', - ] + """Serializer metaclass.""" + + fields = ['build_item', 'stock_item', 'quantity', 'output'] build_line = serializers.PrimaryKeyRelatedField( queryset=BuildLine.objects.all(), @@ -917,17 +918,22 @@ class BuildAllocationItemSerializer(serializers.Serializer): ) def validate_build_line(self, build_line): - """Check if the parts match""" + """Check if the parts match.""" build = self.context['build'] # BomItem should point to the same 'part' as the parent build if build.part != build_line.bom_item.part: - # If not, it may be marked as "inherited" from a parent part - if build_line.bom_item.inherited and build.part in build_line.bom_item.part.get_descendants(include_self=False): + if ( + build_line.bom_item.inherited + and build.part + in build_line.bom_item.part.get_descendants(include_self=False) + ): pass else: - raise ValidationError(_("bom_item.part must point to the same part as the build order")) + raise ValidationError( + _('bom_item.part must point to the same part as the build order') + ) return build_line @@ -940,23 +946,20 @@ class BuildAllocationItemSerializer(serializers.Serializer): ) def validate_stock_item(self, stock_item): - """Perform validation of the stock_item field""" + """Perform validation of the stock_item field.""" if not stock_item.in_stock: - raise ValidationError(_("Item must be in stock")) + raise ValidationError(_('Item must be in stock')) return stock_item quantity = serializers.DecimalField( - max_digits=15, - decimal_places=5, - min_value=Decimal(0), - required=True + max_digits=15, decimal_places=5, min_value=Decimal(0), required=True ) def validate_quantity(self, quantity): - """Perform validation of the 'quantity' field""" + """Perform validation of the 'quantity' field.""" if quantity <= 0: - raise ValidationError(_("Quantity must be greater than zero")) + raise ValidationError(_('Quantity must be greater than zero')) return quantity @@ -969,7 +972,7 @@ class BuildAllocationItemSerializer(serializers.Serializer): ) def validate(self, data): - """Perform data validation for this item""" + """Perform data validation for this item.""" super().validate(data) build_line = data['build_line'] @@ -986,24 +989,24 @@ class BuildAllocationItemSerializer(serializers.Serializer): q = stock_item.unallocated_quantity() if quantity > q: - q = InvenTree.helpers.clean_decimal(q) - raise ValidationError({ - 'quantity': _(f"Available quantity ({q}) exceeded") - }) + raise ValidationError({'quantity': _(f'Available quantity ({q}) exceeded')}) # Output *must* be set for trackable parts if output is None and build_line.bom_item.sub_part.trackable: raise ValidationError({ - 'output': _('Build output must be specified for allocation of tracked parts'), + 'output': _( + 'Build output must be specified for allocation of tracked parts' + ) }) # Output *cannot* be set for un-tracked parts if output is not None and not build_line.bom_item.sub_part.trackable: - raise ValidationError({ - 'output': _('Build output cannot be specified for allocation of untracked parts'), + 'output': _( + 'Build output cannot be specified for allocation of untracked parts' + ) }) return data @@ -1013,10 +1016,9 @@ class BuildAllocationSerializer(serializers.Serializer): """DRF serializer for allocation stock items against a build order.""" class Meta: - """Serializer metaclass""" - fields = [ - 'items', - ] + """Serializer metaclass.""" + + fields = ['items'] items = BuildAllocationItemSerializer(many=True) @@ -1032,7 +1034,7 @@ class BuildAllocationSerializer(serializers.Serializer): return data def save(self): - """Perform the allocation""" + """Perform the allocation.""" data = self.validated_data items = data.get('items', []) @@ -1049,9 +1051,9 @@ class BuildAllocationSerializer(serializers.Serializer): continue params = { - "build_line": build_line, - "stock_item": stock_item, - "install_into": output, + 'build_line': build_line, + 'stock_item': stock_item, + 'install_into': output, } try: @@ -1063,8 +1065,7 @@ class BuildAllocationSerializer(serializers.Serializer): else: # Create a new BuildItem to allocate stock build_item = BuildItem.objects.create( - quantity=quantity, - **params + quantity=quantity, **params ) except (ValidationError, DjangoValidationError) as exc: # Catch model errors and re-throw as DRF errors @@ -1075,7 +1076,8 @@ class BuildAutoAllocationSerializer(serializers.Serializer): """DRF serializer for auto allocating stock items against a build order.""" class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" + fields = [ 'location', 'exclude_location', @@ -1090,7 +1092,9 @@ class BuildAutoAllocationSerializer(serializers.Serializer): allow_null=True, required=False, label=_('Source Location'), - help_text=_('Stock location where parts are to be sourced (leave blank to take from any location)'), + help_text=_( + 'Stock location where parts are to be sourced (leave blank to take from any location)' + ), ) exclude_location = serializers.PrimaryKeyRelatedField( @@ -1121,8 +1125,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer): ) def save(self): - """Perform the auto-allocation step""" - + """Perform the auto-allocation step.""" import build.tasks import InvenTree.tasks @@ -1138,9 +1141,9 @@ class BuildAutoAllocationSerializer(serializers.Serializer): interchangeable=data['interchangeable'], substitutes=data['substitutes'], optional_items=data['optional_items'], - group='build' + group='build', ): - raise ValidationError(_("Failed to start auto-allocation task")) + raise ValidationError(_('Failed to start auto-allocation task')) class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): @@ -1165,7 +1168,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali ] class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" + model = BuildItem fields = [ 'pk', @@ -1175,14 +1179,12 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'stock_item', 'quantity', 'location', - # Detail fields, can be included or excluded 'build_detail', 'location_detail', 'part_detail', 'stock_item_detail', 'supplier_part_detail', - # The following fields are only used for data export 'bom_reference', 'bom_part_id', @@ -1202,7 +1204,7 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali ] def __init__(self, *args, **kwargs): - """Determine which extra details fields should be included""" + """Determine which extra details fields should be included.""" part_detail = kwargs.pop('part_detail', True) location_detail = kwargs.pop('location_detail', True) stock_detail = kwargs.pop('stock_detail', True) @@ -1223,55 +1225,103 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali self.fields.pop('build_detail', None) # Export-only fields - sku = serializers.CharField(source='stock_item.supplier_part.SKU', label=_('Supplier Part Number'), read_only=True) - mpn = serializers.CharField(source='stock_item.supplier_part.manufacturer_part.MPN', label=_('Manufacturer Part Number'), read_only=True) - location_name = serializers.CharField(source='stock_item.location.name', label=_('Location Name'), read_only=True) - build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True) - bom_reference = serializers.CharField(source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True) - item_packaging = serializers.CharField(source='stock_item.packaging', label=_('Packaging'), read_only=True) + sku = serializers.CharField( + source='stock_item.supplier_part.SKU', + label=_('Supplier Part Number'), + read_only=True, + ) + mpn = serializers.CharField( + source='stock_item.supplier_part.manufacturer_part.MPN', + label=_('Manufacturer Part Number'), + read_only=True, + ) + location_name = serializers.CharField( + source='stock_item.location.name', label=_('Location Name'), read_only=True + ) + build_reference = serializers.CharField( + source='build.reference', label=_('Build Reference'), read_only=True + ) + bom_reference = serializers.CharField( + source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True + ) + item_packaging = serializers.CharField( + source='stock_item.packaging', label=_('Packaging'), read_only=True + ) # Part detail fields - part_id = serializers.PrimaryKeyRelatedField(source='stock_item.part', label=_('Part ID'), many=False, read_only=True) - part_name = serializers.CharField(source='stock_item.part.name', label=_('Part Name'), read_only=True) - part_ipn = serializers.CharField(source='stock_item.part.IPN', label=_('Part IPN'), read_only=True) - part_description = serializers.CharField(source='stock_item.part.description', label=_('Part Description'), read_only=True) + part_id = serializers.PrimaryKeyRelatedField( + source='stock_item.part', label=_('Part ID'), many=False, read_only=True + ) + part_name = serializers.CharField( + source='stock_item.part.name', label=_('Part Name'), read_only=True + ) + part_ipn = serializers.CharField( + source='stock_item.part.IPN', label=_('Part IPN'), read_only=True + ) + part_description = serializers.CharField( + source='stock_item.part.description', + label=_('Part Description'), + read_only=True, + ) # BOM Item Part ID (it may be different to the allocated part) - bom_part_id = serializers.PrimaryKeyRelatedField(source='build_line.bom_item.sub_part', label=_('BOM Part ID'), many=False, read_only=True) - bom_part_name = serializers.CharField(source='build_line.bom_item.sub_part.name', label=_('BOM Part Name'), read_only=True) + bom_part_id = serializers.PrimaryKeyRelatedField( + source='build_line.bom_item.sub_part', + label=_('BOM Part ID'), + many=False, + read_only=True, + ) + bom_part_name = serializers.CharField( + source='build_line.bom_item.sub_part.name', + label=_('BOM Part Name'), + read_only=True, + ) - item_batch_code = serializers.CharField(source='stock_item.batch', label=_('Batch Code'), read_only=True) - item_serial_number = serializers.CharField(source='stock_item.serial', label=_('Serial Number'), read_only=True) + item_batch_code = serializers.CharField( + source='stock_item.batch', label=_('Batch Code'), read_only=True + ) + item_serial_number = serializers.CharField( + source='stock_item.serial', label=_('Serial Number'), read_only=True + ) # Annotated fields - build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True) + build = serializers.PrimaryKeyRelatedField( + source='build_line.build', many=False, read_only=True + ) # Extra (optional) detail fields - part_detail = part_serializers.PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False) + part_detail = part_serializers.PartBriefSerializer( + source='stock_item.part', many=False, read_only=True, pricing=False + ) stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) - location = serializers.PrimaryKeyRelatedField(source='stock_item.location', many=False, read_only=True) - location_detail = LocationBriefSerializer(source='stock_item.location', read_only=True) - build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True) - supplier_part_detail = company.serializers.SupplierPartSerializer(source='stock_item.supplier_part', many=False, read_only=True, brief=True) + location = serializers.PrimaryKeyRelatedField( + source='stock_item.location', many=False, read_only=True + ) + location_detail = LocationBriefSerializer( + source='stock_item.location', read_only=True + ) + build_detail = BuildSerializer( + source='build_line.build', many=False, read_only=True + ) + supplier_part_detail = company.serializers.SupplierPartSerializer( + source='stock_item.supplier_part', many=False, read_only=True, brief=True + ) quantity = InvenTreeDecimalField(label=_('Allocated Quantity')) - available_quantity = InvenTreeDecimalField(source='stock_item.quantity', read_only=True, label=_('Available Quantity')) + available_quantity = InvenTreeDecimalField( + source='stock_item.quantity', read_only=True, label=_('Available Quantity') + ) class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): """Serializer for a BuildItem object.""" - export_exclude_fields = [ - 'allocations', - ] + export_exclude_fields = ['allocations'] - export_only_fields = [ - 'part_description', - 'part_category_name', - ] + export_only_fields = ['part_description', 'part_category_name'] class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" model = BuildLine fields = [ @@ -1279,10 +1329,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'build', 'bom_item', 'quantity', - # Build detail fields 'build_reference', - # BOM item detail fields 'reference', 'consumable', @@ -1291,13 +1339,11 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'trackable', 'inherited', 'allow_variants', - # Part detail fields 'part', 'part_name', 'part_IPN', 'part_category_id', - # Annotated fields 'allocated', 'in_production', @@ -1306,28 +1352,21 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'available_substitute_stock', 'available_variant_stock', 'external_stock', - # Related fields 'allocations', - # Extra fields only for data export 'part_description', 'part_category_name', - # Extra detail (related field) serializers 'bom_item_detail', 'part_detail', 'build_detail', ] - read_only_fields = [ - 'build', - 'bom_item', - 'allocations', - ] + read_only_fields = ['build', 'bom_item', 'allocations'] def __init__(self, *args, **kwargs): - """Determine which extra details fields should be included""" + """Determine which extra details fields should be included.""" part_detail = kwargs.pop('part_detail', True) build_detail = kwargs.pop('build_detail', False) @@ -1340,27 +1379,59 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali self.fields.pop('build_detail', None) # Build info fields - build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True) + build_reference = serializers.CharField( + source='build.reference', label=_('Build Reference'), read_only=True + ) # Part info fields - part = serializers.PrimaryKeyRelatedField(source='bom_item.sub_part', label=_('Part'), many=False, read_only=True) - part_name = serializers.CharField(source='bom_item.sub_part.name', label=_('Part Name'), read_only=True) - part_IPN = serializers.CharField(source='bom_item.sub_part.IPN', label=_('Part IPN'), read_only=True) + part = serializers.PrimaryKeyRelatedField( + source='bom_item.sub_part', label=_('Part'), many=False, read_only=True + ) + part_name = serializers.CharField( + source='bom_item.sub_part.name', label=_('Part Name'), read_only=True + ) + part_IPN = serializers.CharField( # noqa: N815 + source='bom_item.sub_part.IPN', label=_('Part IPN'), read_only=True + ) - part_description = serializers.CharField(source='bom_item.sub_part.description', label=_('Part Description'), read_only=True) - part_category_id = serializers.PrimaryKeyRelatedField(source='bom_item.sub_part.category', label=_('Part Category ID'), read_only=True) - part_category_name = serializers.CharField(source='bom_item.sub_part.category.name', label=_('Part Category Name'), read_only=True) + part_description = serializers.CharField( + source='bom_item.sub_part.description', + label=_('Part Description'), + read_only=True, + ) + part_category_id = serializers.PrimaryKeyRelatedField( + source='bom_item.sub_part.category', label=_('Part Category ID'), read_only=True + ) + part_category_name = serializers.CharField( + source='bom_item.sub_part.category.name', + label=_('Part Category Name'), + read_only=True, + ) allocations = BuildItemSerializer(many=True, read_only=True) # BOM item info fields - reference = serializers.CharField(source='bom_item.reference', label=_('Reference'), read_only=True) - consumable = serializers.BooleanField(source='bom_item.consumable', label=_('Consumable'), read_only=True) - optional = serializers.BooleanField(source='bom_item.optional', label=_('Optional'), read_only=True) - testable = serializers.BooleanField(source='bom_item.sub_part.testable', label=_('Testable'), read_only=True) - trackable = serializers.BooleanField(source='bom_item.sub_part.trackable', label=_('Trackable'), read_only=True) - inherited = serializers.BooleanField(source='bom_item.inherited', label=_('Inherited'), read_only=True) - allow_variants = serializers.BooleanField(source='bom_item.allow_variants', label=_('Allow Variants'), read_only=True) + reference = serializers.CharField( + source='bom_item.reference', label=_('Reference'), read_only=True + ) + consumable = serializers.BooleanField( + source='bom_item.consumable', label=_('Consumable'), read_only=True + ) + optional = serializers.BooleanField( + source='bom_item.optional', label=_('Optional'), read_only=True + ) + testable = serializers.BooleanField( + source='bom_item.sub_part.testable', label=_('Testable'), read_only=True + ) + trackable = serializers.BooleanField( + source='bom_item.sub_part.trackable', label=_('Trackable'), read_only=True + ) + inherited = serializers.BooleanField( + source='bom_item.inherited', label=_('Inherited'), read_only=True + ) + allow_variants = serializers.BooleanField( + source='bom_item.allow_variants', label=_('Allow Variants'), read_only=True + ) quantity = serializers.FloatField(label=_('Quantity')) @@ -1374,39 +1445,39 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali pricing=False, substitutes=False, sub_part_detail=False, - part_detail=False + part_detail=False, ) - part_detail = part_serializers.PartBriefSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False) - build_detail = BuildSerializer(source='build', part_detail=False, many=False, read_only=True) + part_detail = part_serializers.PartBriefSerializer( + source='bom_item.sub_part', many=False, read_only=True, pricing=False + ) + build_detail = BuildSerializer( + source='build', part_detail=False, many=False, read_only=True + ) # Annotated (calculated) fields # Total quantity of allocated stock - allocated = serializers.FloatField( - label=_('Allocated Stock'), - read_only=True - ) + allocated = serializers.FloatField(label=_('Allocated Stock'), read_only=True) - on_order = serializers.FloatField( - label=_('On Order'), - read_only=True - ) + on_order = serializers.FloatField(label=_('On Order'), read_only=True) - in_production = serializers.FloatField( - label=_('In Production'), - read_only=True - ) + in_production = serializers.FloatField(label=_('In Production'), read_only=True) external_stock = serializers.FloatField(read_only=True, label=_('External Stock')) available_stock = serializers.FloatField(read_only=True, label=_('Available Stock')) - available_substitute_stock = serializers.FloatField(read_only=True, label=_('Available Substitute Stock')) - available_variant_stock = serializers.FloatField(read_only=True, label=_('Available Variant Stock')) + available_substitute_stock = serializers.FloatField( + read_only=True, label=_('Available Substitute Stock') + ) + available_variant_stock = serializers.FloatField( + read_only=True, label=_('Available Variant Stock') + ) @staticmethod def annotate_queryset(queryset, build=None): - """Add extra annotations to the queryset: + """Add extra annotations to the queryset. + Annotations: - allocated: Total stock quantity allocated against this build line - available: Total stock available for allocation against this build line - on_order: Total stock on order for this build line @@ -1427,7 +1498,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'bom_item__part', 'bom_item__part__pricing_data', 'bom_item__sub_part', - 'bom_item__sub_part__pricing_data' + 'bom_item__sub_part__pricing_data', ) # Pre-fetch related fields @@ -1436,11 +1507,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'allocations__stock_item', 'allocations__stock_item__part', 'allocations__stock_item__location', - 'bom_item__sub_part__stock_items', 'bom_item__sub_part__stock_items__allocations', 'bom_item__sub_part__stock_items__sales_order_allocations', - 'bom_item__substitutes', 'bom_item__substitutes__part__stock_items', 'bom_item__substitutes__part__stock_items__allocations', @@ -1449,58 +1518,60 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali # Defer expensive fields which we do not need for this serializer - queryset = queryset.defer( - 'build__lft', - 'build__rght', - 'build__level', - 'build__tree_id', - 'build__destination', - 'build__take_from', - 'build__completed_by', - 'build__issued_by', - 'build__sales_order', - 'build__parent', - 'build__notes', - 'build__metadata', - 'build__responsible', - 'build__barcode_data', - 'build__barcode_hash', - 'build__project_code', - ).defer( - 'bom_item__metadata' - ).defer( - 'bom_item__part__lft', - 'bom_item__part__rght', - 'bom_item__part__level', - 'bom_item__part__tree_id', - 'bom_item__part__tags', - 'bom_item__part__notes', - 'bom_item__part__variant_of', - 'bom_item__part__revision_of', - 'bom_item__part__creation_user', - 'bom_item__part__bom_checked_by', - 'bom_item__part__default_supplier', - 'bom_item__part__responsible_owner', - ).defer( - 'bom_item__sub_part__lft', - 'bom_item__sub_part__rght', - 'bom_item__sub_part__level', - 'bom_item__sub_part__tree_id', - 'bom_item__sub_part__tags', - 'bom_item__sub_part__notes', - 'bom_item__sub_part__variant_of', - 'bom_item__sub_part__revision_of', - 'bom_item__sub_part__creation_user', - 'bom_item__sub_part__bom_checked_by', - 'bom_item__sub_part__default_supplier', - 'bom_item__sub_part__responsible_owner', + queryset = ( + queryset.defer( + 'build__lft', + 'build__rght', + 'build__level', + 'build__tree_id', + 'build__destination', + 'build__take_from', + 'build__completed_by', + 'build__issued_by', + 'build__sales_order', + 'build__parent', + 'build__notes', + 'build__metadata', + 'build__responsible', + 'build__barcode_data', + 'build__barcode_hash', + 'build__project_code', + ) + .defer('bom_item__metadata') + .defer( + 'bom_item__part__lft', + 'bom_item__part__rght', + 'bom_item__part__level', + 'bom_item__part__tree_id', + 'bom_item__part__tags', + 'bom_item__part__notes', + 'bom_item__part__variant_of', + 'bom_item__part__revision_of', + 'bom_item__part__creation_user', + 'bom_item__part__bom_checked_by', + 'bom_item__part__default_supplier', + 'bom_item__part__responsible_owner', + ) + .defer( + 'bom_item__sub_part__lft', + 'bom_item__sub_part__rght', + 'bom_item__sub_part__level', + 'bom_item__sub_part__tree_id', + 'bom_item__sub_part__tags', + 'bom_item__sub_part__notes', + 'bom_item__sub_part__variant_of', + 'bom_item__sub_part__revision_of', + 'bom_item__sub_part__creation_user', + 'bom_item__sub_part__bom_checked_by', + 'bom_item__sub_part__default_supplier', + 'bom_item__sub_part__responsible_owner', + ) ) # Annotate the "allocated" quantity queryset = queryset.annotate( allocated=Coalesce( - Sum('allocations__quantity'), 0, - output_field=models.DecimalField() + Sum('allocations__quantity'), 0, output_field=models.DecimalField() ) ) @@ -1525,20 +1596,28 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali # Annotate the "on_order" quantity queryset = queryset.annotate( - on_order=part.filters.annotate_on_order_quantity(reference=ref), + on_order=part.filters.annotate_on_order_quantity(reference=ref) ) # Annotate the "available" quantity queryset = queryset.alias( - total_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter), - allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref), - allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref), + total_stock=part.filters.annotate_total_stock( + reference=ref, filter=stock_filter + ), + allocated_to_sales_orders=part.filters.annotate_sales_order_allocations( + reference=ref + ), + allocated_to_build_orders=part.filters.annotate_build_order_allocations( + reference=ref + ), ) # Calculate 'available_stock' based on previously annotated fields queryset = queryset.annotate( available_stock=ExpressionWrapper( - F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'), + F('total_stock') + - F('allocated_to_sales_orders') + - F('allocated_to_build_orders'), output_field=models.DecimalField(), ) ) @@ -1550,38 +1629,58 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali # Add 'external stock' annotations queryset = queryset.annotate( - external_stock=part.filters.annotate_total_stock(reference=ref, filter=external_stock_filter) + external_stock=part.filters.annotate_total_stock( + reference=ref, filter=external_stock_filter + ) ) ref = 'bom_item__substitutes__part__' # Extract similar information for any 'substitute' parts queryset = queryset.alias( - substitute_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter), - substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref), - substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref) + substitute_stock=part.filters.annotate_total_stock( + reference=ref, filter=stock_filter + ), + substitute_build_allocations=part.filters.annotate_build_order_allocations( + reference=ref + ), + substitute_sales_allocations=part.filters.annotate_sales_order_allocations( + reference=ref + ), ) # Calculate 'available_substitute_stock' field queryset = queryset.annotate( available_substitute_stock=ExpressionWrapper( - F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'), + F('substitute_stock') + - F('substitute_build_allocations') + - F('substitute_sales_allocations'), output_field=models.DecimalField(), ) ) # Annotate the queryset with 'available variant stock' information - variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__', filter=stock_filter) + variant_stock_query = part.filters.variant_stock_query( + reference='bom_item__sub_part__', filter=stock_filter + ) queryset = queryset.alias( - variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'), - variant_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'), - variant_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'), + variant_stock_total=part.filters.annotate_variant_quantity( + variant_stock_query, reference='quantity' + ), + variant_bo_allocations=part.filters.annotate_variant_quantity( + variant_stock_query, reference='sales_order_allocations__quantity' + ), + variant_so_allocations=part.filters.annotate_variant_quantity( + variant_stock_query, reference='allocations__quantity' + ), ) queryset = queryset.annotate( available_variant_stock=ExpressionWrapper( - F('variant_stock_total') - F('variant_bo_allocations') - F('variant_so_allocations'), + F('variant_stock_total') + - F('variant_bo_allocations') + - F('variant_so_allocations'), output_field=FloatField(), ) ) diff --git a/src/backend/InvenTree/build/status_codes.py b/src/backend/InvenTree/build/status_codes.py index 1c8e66de30..75bf3945cc 100644 --- a/src/backend/InvenTree/build/status_codes.py +++ b/src/backend/InvenTree/build/status_codes.py @@ -24,6 +24,4 @@ class BuildStatusGroups: BuildStatus.PRODUCTION.value, ] - COMPLETE = [ - BuildStatus.COMPLETE.value, - ] + COMPLETE = [BuildStatus.COMPLETE.value] diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index 995a897c5f..0cd00c2329 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -3,13 +3,12 @@ import logging import random import time - from datetime import timedelta from decimal import Decimal from django.contrib.auth.models import User -from django.template.loader import render_to_string from django.db import transaction +from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from allauth.account.models import EmailAddress @@ -34,7 +33,10 @@ def auto_allocate_build(build_id: int, **kwargs): build_order = build_models.Build.objects.filter(pk=build_id).first() if not build_order: - logger.warning("Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist", build_id) + logger.warning( + 'Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist', + build_id, + ) return build_order.auto_allocate_stock(**kwargs) @@ -48,13 +50,19 @@ def complete_build_allocations(build_id: int, user_id: int): try: user = User.objects.get(pk=user_id) except User.DoesNotExist: - logger.warning("Could not complete build allocations for BuildOrder <%s> - User does not exist", build_id) + logger.warning( + 'Could not complete build allocations for BuildOrder <%s> - User does not exist', + build_id, + ) return else: user = None if not build_order: - logger.warning("Could not complete build allocations for BuildOrder <%s> - BuildOrder does not exist", build_id) + logger.warning( + 'Could not complete build allocations for BuildOrder <%s> - BuildOrder does not exist', + build_id, + ) return build_order.complete_allocations(user) @@ -65,7 +73,7 @@ def update_build_order_lines(bom_item_pk: int): This task is triggered when a BomItem is created or updated. """ - logger.info("Updating build order lines for BomItem %s", bom_item_pk) + logger.info('Updating build order lines for BomItem %s', bom_item_pk) bom_item = part_models.BomItem.objects.filter(pk=bom_item_pk).first() @@ -77,16 +85,14 @@ def update_build_order_lines(bom_item_pk: int): # Find all active builds which reference any of the parts builds = build_models.Build.objects.filter( - part__in=list(assemblies), - status__in=BuildStatusGroups.ACTIVE_CODES + part__in=list(assemblies), status__in=BuildStatusGroups.ACTIVE_CODES ) # Iterate through each build, and update the relevant line items for bo in builds: # Try to find a matching build order line line = build_models.BuildLine.objects.filter( - build=bo, - bom_item=bom_item, + build=bo, bom_item=bom_item ).first() q = bom_item.get_required_quantity(bo.quantity) @@ -99,13 +105,13 @@ def update_build_order_lines(bom_item_pk: int): else: # Create a new line item build_models.BuildLine.objects.create( - build=bo, - bom_item=bom_item, - quantity=q, + build=bo, bom_item=bom_item, quantity=q ) if builds.count() > 0: - logger.info("Updated %s build orders for part %s", builds.count(), bom_item.part) + logger.info( + 'Updated %s build orders for part %s', builds.count(), bom_item.part + ) def check_build_stock(build: build_models.Build): @@ -133,7 +139,6 @@ def check_build_stock(build: build_models.Build): return for bom_item in part.get_bom_items(): - sub_part = bom_item.sub_part # The 'in stock' quantity depends on whether the bom_item allows variants @@ -149,7 +154,9 @@ def check_build_stock(build: build_models.Build): # There is not sufficient stock for this part lines.append({ - 'link': InvenTree.helpers_model.construct_absolute_url(sub_part.get_absolute_url()), + 'link': InvenTree.helpers_model.construct_absolute_url( + sub_part.get_absolute_url() + ), 'part': sub_part, 'in_stock': in_stock, 'allocated': allocated, @@ -164,29 +171,32 @@ def check_build_stock(build: build_models.Build): # Are there any users subscribed to these parts? subscribers = build.part.get_subscribers() - emails = EmailAddress.objects.filter( - user__in=subscribers, - ) + emails = EmailAddress.objects.filter(user__in=subscribers) if len(emails) > 0: - - logger.info("Notifying users of stock required for build %s", build.pk) + logger.info('Notifying users of stock required for build %s', build.pk) context = { - 'link': InvenTree.helpers_model.construct_absolute_url(build.get_absolute_url()), + 'link': InvenTree.helpers_model.construct_absolute_url( + build.get_absolute_url() + ), 'build': build, 'part': build.part, 'lines': lines, } # Render the HTML message - html_message = render_to_string('email/build_order_required_stock.html', context) + html_message = render_to_string( + 'email/build_order_required_stock.html', context + ) - subject = _("Stock required for build order") + subject = _('Stock required for build order') recipients = emails.values_list('email', flat=True) - InvenTree.helpers_email.send_email(subject, '', recipients, html_message=html_message) + InvenTree.helpers_email.send_email( + subject, '', recipients, html_message=html_message + ) def create_child_builds(build_id: int) -> None: @@ -195,7 +205,6 @@ def create_child_builds(build_id: int) -> None: - Will create a build order for each assembly part in the BOM - Runs recursively, also creating child builds for each sub-assembly part """ - try: build_order = build_models.Build.objects.get(pk=build_id) except (build_models.Build.DoesNotExist, ValueError): @@ -215,13 +224,12 @@ def create_child_builds(build_id: int) -> None: for item in assembly_items: quantity = item.quantity * build_order.quantity - # Check if the child build order has already been created if build_models.Build.objects.filter( part=item.sub_part, parent=build_order, quantity=quantity, - status__in=BuildStatusGroups.ACTIVE_CODES + status__in=BuildStatusGroups.ACTIVE_CODES, ).exists(): continue @@ -241,11 +249,7 @@ def create_child_builds(build_id: int) -> None: for pk in sub_build_ids: # Offload the child build order creation to the background task queue - InvenTree.tasks.offload_task( - create_child_builds, - pk, - group='build' - ) + InvenTree.tasks.offload_task(create_child_builds, pk, group='build') def notify_overdue_build_order(bo: build_models.Build): @@ -263,24 +267,16 @@ def notify_overdue_build_order(bo: build_models.Build): context = { 'order': bo, 'name': name, - 'message': _(f"Build order {bo} is now overdue"), - 'link': InvenTree.helpers_model.construct_absolute_url( - bo.get_absolute_url(), - ), - 'template': { - 'html': 'email/overdue_build_order.html', - 'subject': name, - } + 'message': _(f'Build order {bo} is now overdue'), + 'link': InvenTree.helpers_model.construct_absolute_url(bo.get_absolute_url()), + 'template': {'html': 'email/overdue_build_order.html', 'subject': name}, } event_name = BuildEvents.OVERDUE # Send a notification to the appropriate users common.notifications.trigger_notification( - bo, - event_name, - targets=targets, - context=context + bo, event_name, targets=targets, context=context ) # Register a matching event to the plugin system @@ -298,8 +294,7 @@ def check_overdue_build_orders(): yesterday = InvenTree.helpers.current_date() - timedelta(days=1) overdue_orders = build_models.Build.objects.filter( - target_date=yesterday, - status__in=BuildStatusGroups.ACTIVE_CODES + target_date=yesterday, status__in=BuildStatusGroups.ACTIVE_CODES ) for bo in overdue_orders: diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 07555cb63e..887e743292 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -1,4 +1,4 @@ -"""Unit tests for the BuildOrder API""" +"""Unit tests for the BuildOrder API.""" from datetime import datetime, timedelta @@ -6,13 +6,12 @@ from django.urls import reverse from rest_framework import status -from part.models import Part, BomItem from build.models import Build, BuildItem, BuildLine -from stock.models import StockItem - from build.status_codes import BuildStatus -from stock.status_codes import StockStatus from InvenTree.unit_test import InvenTreeAPITestCase +from part.models import BomItem, Part +from stock.models import StockItem +from stock.status_codes import StockStatus class TestBuildAPI(InvenTreeAPITestCase): @@ -22,18 +21,9 @@ class TestBuildAPI(InvenTreeAPITestCase): - Tests for BuildItem API """ - fixtures = [ - 'category', - 'part', - 'location', - 'build', - ] + fixtures = ['category', 'part', 'location', 'build'] - roles = [ - 'build.change', - 'build.add', - 'build.delete', - ] + roles = ['build.change', 'build.add', 'build.delete'] def test_get_build_list(self): """Test that we can retrieve list of build objects.""" @@ -45,7 +35,9 @@ class TestBuildAPI(InvenTreeAPITestCase): self.assertEqual(len(response.data), 5) # Filter query by build status - response = self.get(url, {'status': BuildStatus.COMPLETE.value}, expected_code=200) + response = self.get( + url, {'status': BuildStatus.COMPLETE.value}, expected_code=200 + ) self.assertEqual(len(response.data), 4) @@ -88,27 +80,17 @@ class TestBuildAPI(InvenTreeAPITestCase): class BuildAPITest(InvenTreeAPITestCase): """Series of tests for the Build DRF API.""" - fixtures = [ - 'category', - 'part', - 'location', - 'bom', - 'build', - 'stock', - ] + fixtures = ['category', 'part', 'location', 'bom', 'build', 'stock'] # Required roles to access Build API endpoints - roles = [ - 'build.change', - 'build.add', - ] + roles = ['build.change', 'build.add'] class BuildTest(BuildAPITest): """Unit testing for the build complete API endpoint.""" def setUp(self): - """Basic setup for this test suite""" + """Basic setup for this test suite.""" super().setUp() self.build = Build.objects.get(pk=1) @@ -121,79 +103,47 @@ class BuildTest(BuildAPITest): self.post( reverse('api-build-output-complete', kwargs={'pk': 99999}), {}, - expected_code=400 + expected_code=400, ) data = self.post(self.url, {}, expected_code=400).data - self.assertIn("This field is required", str(data['outputs'])) - self.assertIn("This field is required", str(data['location'])) + self.assertIn('This field is required', str(data['outputs'])) + self.assertIn('This field is required', str(data['location'])) # Test with an invalid location data = self.post( - self.url, - { - "outputs": [], - "location": 999999, - }, - expected_code=400 + self.url, {'outputs': [], 'location': 999999}, expected_code=400 ).data - self.assertIn( - "Invalid pk", - str(data["location"]) - ) + self.assertIn('Invalid pk', str(data['location'])) data = self.post( - self.url, - { - "outputs": [], - "location": 1, - }, - expected_code=400 + self.url, {'outputs': [], 'location': 1}, expected_code=400 ).data - self.assertIn("A list of build outputs must be provided", str(data)) + self.assertIn('A list of build outputs must be provided', str(data)) - stock_item = StockItem.objects.create( - part=self.build.part, - quantity=100, - ) + stock_item = StockItem.objects.create(part=self.build.part, quantity=100) - post_data = { - "outputs": [ - { - "output": stock_item.pk, - }, - ], - "location": 1, - } + post_data = {'outputs': [{'output': stock_item.pk}], 'location': 1} # Post with a stock item that does not match the build - data = self.post( - self.url, - post_data, - expected_code=400 - ).data + data = self.post(self.url, post_data, expected_code=400).data self.assertIn( - "Build output does not match the parent build", - str(data["outputs"][0]) + 'Build output does not match the parent build', str(data['outputs'][0]) ) # Now, ensure that the stock item *does* match the build stock_item.build = self.build stock_item.save() - data = self.post( - self.url, - post_data, - expected_code=400, - ).data + data = self.post(self.url, post_data, expected_code=400).data self.assertIn( - "This build output has already been completed", - str(data["outputs"][0]["output"]) + 'This build output has already been completed', + str(data['outputs'][0]['output']), ) def test_complete(self): @@ -219,9 +169,9 @@ class BuildTest(BuildAPITest): self.post( self.url, { - "outputs": [{"output": output.pk} for output in outputs], - "location": 1, - "status": StockStatus.ATTENTION.value, + 'outputs': [{'output': output.pk} for output in outputs], + 'location': 1, + 'status': StockStatus.ATTENTION.value, }, expected_code=201, max_query_count=600, # TODO: Try to optimize this @@ -243,22 +193,12 @@ class BuildTest(BuildAPITest): # Try to complete the build (it should fail) finish_url = reverse('api-build-finish', kwargs={'pk': self.build.pk}) - response = self.post( - finish_url, - {}, - expected_code=400 - ) + response = self.post(finish_url, {}, expected_code=400) self.assertIn('accept_unallocated', response.data) # Accept unallocated stock - self.post( - finish_url, - { - 'accept_unallocated': True, - }, - expected_code=201, - ) + self.post(finish_url, {'accept_unallocated': True}, expected_code=201) self.build.refresh_from_db() @@ -274,16 +214,10 @@ class BuildTest(BuildAPITest): def make_new_build(ref): """Make a new build order, and allocate stock to it.""" - data = self.post( reverse('api-build-list'), - { - 'part': 100, - 'quantity': 10, - 'title': 'Test build', - 'reference': ref, - }, - expected_code=201 + {'part': 100, 'quantity': 10, 'title': 'Test build', 'reference': ref}, + expected_code=201, ).data build = Build.objects.get(pk=data['pk']) @@ -324,33 +258,24 @@ class BuildTest(BuildAPITest): self.assertGreater(bo.consumed_stock.count(), 0) def test_delete(self): - """Test that we can delete a BuildOrder via the API""" + """Test that we can delete a BuildOrder via the API.""" bo = Build.objects.get(pk=1) url = reverse('api-build-detail', kwargs={'pk': bo.pk}) # At first we do not have the required permissions - self.delete( - url, - expected_code=403, - ) + self.delete(url, expected_code=403) self.assignRole('build.delete') # As build is currently not 'cancelled', it cannot be deleted - self.delete( - url, - expected_code=400, - ) + self.delete(url, expected_code=400) bo.status = BuildStatus.CANCELLED.value bo.save() # Now, we should be able to delete - self.delete( - url, - expected_code=204, - ) + self.delete(url, expected_code=204) with self.assertRaises(Build.DoesNotExist): Build.objects.get(pk=1) @@ -365,56 +290,43 @@ class BuildTest(BuildAPITest): # Attempt to create outputs with invalid data response = self.post( - create_url, - { - 'quantity': 'not a number', - }, - expected_code=400 + create_url, {'quantity': 'not a number'}, expected_code=400 ) self.assertIn('A valid number is required', str(response.data)) for q in [-100, -10.3, 0]: - - response = self.post( - create_url, - { - 'quantity': q, - }, - expected_code=400 - ) + response = self.post(create_url, {'quantity': q}, expected_code=400) if q == 0: self.assertIn('Quantity must be greater than zero', str(response.data)) else: - self.assertIn('Ensure this value is greater than or equal to 0', str(response.data)) + self.assertIn( + 'Ensure this value is greater than or equal to 0', + str(response.data), + ) # Mark the part being built as 'trackable' (requires integer quantity) bo.part.trackable = True bo.part.save() - response = self.post( - create_url, - { - 'quantity': 12.3, - }, - expected_code=400 - ) + response = self.post(create_url, {'quantity': 12.3}, expected_code=400) - self.assertIn('Integer quantity required for trackable parts', str(response.data)) + self.assertIn( + 'Integer quantity required for trackable parts', str(response.data) + ) # Erroneous serial numbers response = self.post( create_url, - { - 'quantity': 5, - 'serial_numbers': '1, 2, 3, 4, 5, 6', - 'batch': 'my-batch', - }, - expected_code=400 + {'quantity': 5, 'serial_numbers': '1, 2, 3, 4, 5, 6', 'batch': 'my-batch'}, + expected_code=400, ) - self.assertIn('Number of unique serial numbers (6) must match quantity (5)', str(response.data)) + self.assertIn( + 'Number of unique serial numbers (6) must match quantity (5)', + str(response.data), + ) # At this point, no new build outputs should have been created self.assertEqual(n_outputs, bo.output_count) @@ -422,11 +334,7 @@ class BuildTest(BuildAPITest): # Now, create with *good* data self.post( create_url, - { - 'quantity': 5, - 'serial_numbers': '1, 2, 3, 4, 5', - 'batch': 'my-batch', - }, + {'quantity': 5, 'serial_numbers': '1, 2, 3, 4, 5', 'batch': 'my-batch'}, expected_code=201, ) @@ -435,15 +343,13 @@ class BuildTest(BuildAPITest): # Attempt to create with identical serial numbers response = self.post( - create_url, - { - 'quantity': 3, - 'serial_numbers': '1-3', - }, - expected_code=400, + create_url, {'quantity': 3, 'serial_numbers': '1-3'}, expected_code=400 ) - self.assertIn('The following serial numbers already exist or are invalid : 1,2,3', str(response.data)) + self.assertIn( + 'The following serial numbers already exist or are invalid : 1,2,3', + str(response.data), + ) # Double check no new outputs have been created self.assertEqual(n_outputs + 5, bo.output_count) @@ -457,13 +363,7 @@ class BuildTest(BuildAPITest): delete_url = reverse('api-build-output-delete', kwargs={'pk': 1}) - response = self.post( - delete_url, - { - 'outputs': [], - }, - expected_code=400 - ) + response = self.post(delete_url, {'outputs': []}, expected_code=400) self.assertIn('A list of build outputs must be provided', str(response.data)) @@ -477,17 +377,13 @@ class BuildTest(BuildAPITest): # Note: One has been completed, so this should fail! response = self.post( delete_url, - { - 'outputs': [ - { - 'output': output.pk, - } for output in outputs - ] - }, - expected_code=400 + {'outputs': [{'output': output.pk} for output in outputs]}, + expected_code=400, ) - self.assertIn('This build output has already been completed', str(response.data)) + self.assertIn( + 'This build output has already been completed', str(response.data) + ) # No change to the build outputs self.assertEqual(n_outputs + 5, bo.output_count) @@ -496,14 +392,8 @@ class BuildTest(BuildAPITest): # Let's delete 2 build outputs self.post( delete_url, - { - 'outputs': [ - { - 'output': output.pk, - } for output in outputs[1:3] - ] - }, - expected_code=201 + {'outputs': [{'output': output.pk} for output in outputs[1:3]]}, + expected_code=201, ) # Two build outputs have been removed @@ -515,12 +405,7 @@ class BuildTest(BuildAPITest): # Let's mark the remaining outputs as complete response = self.post( - complete_url, - { - 'outputs': [], - 'location': 4, - }, - expected_code=400, + complete_url, {'outputs': [], 'location': 4}, expected_code=400 ) self.assertIn('A list of build outputs must be provided', str(response.data)) @@ -532,11 +417,7 @@ class BuildTest(BuildAPITest): self.post( complete_url, { - 'outputs': [ - { - 'output': output.pk - } for output in outputs[3:] - ], + 'outputs': [{'output': output.pk} for output in outputs[3:]], 'location': 4, }, expected_code=201, @@ -553,20 +434,16 @@ class BuildTest(BuildAPITest): # Try again, with an output which has already been completed response = self.post( complete_url, - { - 'outputs': [ - { - 'output': outputs.last().pk, - } - ] - }, + {'outputs': [{'output': outputs.last().pk}]}, expected_code=400, ) - self.assertIn('This build output has already been completed', str(response.data)) + self.assertIn( + 'This build output has already been completed', str(response.data) + ) def test_download_build_orders(self): - """Test that we can download a list of build orders via the API""" + """Test that we can download a list of build orders via the API.""" required_cols = [ 'Reference', 'Build Status', @@ -580,27 +457,17 @@ class BuildTest(BuildAPITest): 'Quantity', ] - excluded_cols = [ - 'lft', 'rght', 'tree_id', 'level', - 'metadata', - ] - - with self.download_file( - reverse('api-build-list'), - { - 'export': 'csv', - } - ) as file: + excluded_cols = ['lft', 'rght', 'tree_id', 'level', 'metadata'] + with self.download_file(reverse('api-build-list'), {'export': 'csv'}) as file: data = self.process_csv( file, required_cols=required_cols, excluded_cols=excluded_cols, - required_rows=Build.objects.count() + required_rows=Build.objects.count(), ) for row in data: - build = Build.objects.get(pk=row['ID']) self.assertEqual(str(build.part.pk), row['Part']) @@ -611,27 +478,24 @@ class BuildTest(BuildAPITest): def test_create(self): """Test creation of new build orders via the API.""" - url = reverse('api-build-list') # First, we'll create a tree of part assemblies - part_a = Part.objects.create(name="Part A", description="Part A description", assembly=True) - part_b = Part.objects.create(name="Part B", description="Part B description", assembly=True) - part_c = Part.objects.create(name="Part C", description="Part C description", assembly=True) + part_a = Part.objects.create( + name='Part A', description='Part A description', assembly=True + ) + part_b = Part.objects.create( + name='Part B', description='Part B description', assembly=True + ) + part_c = Part.objects.create( + name='Part C', description='Part C description', assembly=True + ) # Create a BOM for Part A - BomItem.objects.create( - part=part_a, - sub_part=part_b, - quantity=5, - ) + BomItem.objects.create(part=part_a, sub_part=part_b, quantity=5) # Create a BOM for Part B - BomItem.objects.create( - part=part_b, - sub_part=part_c, - quantity=7 - ) + BomItem.objects.create(part=part_b, sub_part=part_c, quantity=7) n = Build.objects.count() @@ -644,7 +508,7 @@ class BuildTest(BuildAPITest): 'quantity': 10, 'title': 'A build', }, - expected_code=201 + expected_code=201, ) self.assertEqual(n + 1, Build.objects.count()) @@ -662,7 +526,7 @@ class BuildTest(BuildAPITest): 'quantity': 15, 'title': 'A build - with childs', 'create_child_builds': True, - } + }, ) # An addition 1 + 2 builds should have been created @@ -694,7 +558,7 @@ class BuildAllocationTest(BuildAPITest): """ def setUp(self): - """Basic operation as part of test suite setup""" + """Basic operation as part of test suite setup.""" super().setUp() self.assignRole('build.add') @@ -718,7 +582,9 @@ class BuildAllocationTest(BuildAPITest): self.assertEqual(self.build.part.bom_items.count(), 4) # No items yet allocated to this build - self.assertEqual(BuildItem.objects.filter(build_line__build=self.build).count(), 0) + self.assertEqual( + BuildItem.objects.filter(build_line__build=self.build).count(), 0 + ) def test_get(self): """A GET request to the endpoint should return an error.""" @@ -728,7 +594,9 @@ class BuildAllocationTest(BuildAPITest): """An OPTIONS request to the endpoint should return information about the endpoint.""" response = self.options(self.url, expected_code=200) - self.assertIn("API endpoint to allocate stock items to a build order", str(response.data)) + self.assertIn( + 'API endpoint to allocate stock items to a build order', str(response.data) + ) def test_empty(self): """Test without any POST data.""" @@ -738,13 +606,7 @@ class BuildAllocationTest(BuildAPITest): self.assertIn('This field is required', str(data['items'])) # Now test but with an empty items list - data = self.post( - self.url, - { - "items": [] - }, - expected_code=400 - ).data + data = self.post(self.url, {'items': []}, expected_code=400).data self.assertIn('Allocation items must be provided', str(data)) @@ -757,49 +619,35 @@ class BuildAllocationTest(BuildAPITest): data = self.post( self.url, { - "items": [ + 'items': [ { - "build_line": 1, # M2x4 LPHS - "stock_item": 2, # 5,000 screws available + 'build_line': 1, # M2x4 LPHS + 'stock_item': 2, # 5,000 screws available } ] }, - expected_code=400 + expected_code=400, ).data - self.assertIn('This field is required', str(data["items"][0]["quantity"])) + self.assertIn('This field is required', str(data['items'][0]['quantity'])) # Missing bom_item data = self.post( self.url, - { - "items": [ - { - "stock_item": 2, - "quantity": 5000, - } - ] - }, - expected_code=400 + {'items': [{'stock_item': 2, 'quantity': 5000}]}, + expected_code=400, ).data - self.assertIn("This field is required", str(data["items"][0]["build_line"])) + self.assertIn('This field is required', str(data['items'][0]['build_line'])) # Missing stock_item data = self.post( self.url, - { - "items": [ - { - "build_line": 1, - "quantity": 5000, - } - ] - }, - expected_code=400 + {'items': [{'build_line': 1, 'quantity': 5000}]}, + expected_code=400, ).data - self.assertIn("This field is required", str(data["items"][0]["stock_item"])) + self.assertIn('This field is required', str(data['items'][0]['stock_item'])) # No new BuildItem objects have been created during this test self.assertEqual(self.n, BuildItem.objects.count()) @@ -820,15 +668,11 @@ class BuildAllocationTest(BuildAPITest): data = self.post( self.url, { - "items": [ - { - "build_line": wrong_line.pk, - "stock_item": 11, - "quantity": 500, - } + 'items': [ + {'build_line': wrong_line.pk, 'stock_item': 11, 'quantity': 500} ] }, - expected_code=400 + expected_code=400, ).data self.assertIn('Selected stock item does not match BOM line', str(data)) @@ -844,7 +688,6 @@ class BuildAllocationTest(BuildAPITest): right_line = None for line in self.build.build_lines.all(): - if line.bom_item.sub_part.pk == si.part.pk: right_line = line break @@ -852,15 +695,11 @@ class BuildAllocationTest(BuildAPITest): self.post( self.url, { - "items": [ - { - "build_line": right_line.pk, - "stock_item": 2, - "quantity": 5000, - } + 'items': [ + {'build_line': right_line.pk, 'stock_item': 2, 'quantity': 5000} ] }, - expected_code=201 + expected_code=201, ) # A new BuildItem should have been created @@ -889,15 +728,11 @@ class BuildAllocationTest(BuildAPITest): self.post( self.url, { - "items": [ - { - "build_line": right_line.pk, - "stock_item": 2, - "quantity": 3000, - } + 'items': [ + {'build_line': right_line.pk, 'stock_item': 2, 'quantity': 3000} ] }, - expected_code=201 + expected_code=201, ) # A new BuildItem should have been created @@ -913,15 +748,11 @@ class BuildAllocationTest(BuildAPITest): self.post( self.url, { - "items": [ - { - "build_line": right_line.pk, - "stock_item": 2, - "quantity": 2001, - } + 'items': [ + {'build_line': right_line.pk, 'stock_item': 2, 'quantity': 2001} ] }, - expected_code=400 + expected_code=400, ) allocation.refresh_from_db() @@ -931,15 +762,11 @@ class BuildAllocationTest(BuildAPITest): self.post( self.url, { - "items": [ - { - "build_line": right_line.pk, - "stock_item": 2, - "quantity": 2000, - } + 'items': [ + {'build_line': right_line.pk, 'stock_item': 2, 'quantity': 2000} ] }, - expected_code=201 + expected_code=201, ) allocation.refresh_from_db() @@ -950,7 +777,6 @@ class BuildAllocationTest(BuildAPITest): Ref: https://github.com/inventree/InvenTree/issues/6508 """ - si = StockItem.objects.get(pk=2) # Find line item @@ -963,40 +789,29 @@ class BuildAllocationTest(BuildAPITest): self.post( self.url, { - "items": [ - { - "build_line": line.pk, - "stock_item": si.pk, - "quantity": 0.1616, - } + 'items': [ + {'build_line': line.pk, 'stock_item': si.pk, 'quantity': 0.1616} ] }, - expected_code=201 + expected_code=201, ) # Test a fractional quantity when the *available* quantity is less than 1 si = StockItem.objects.create( - part=si.part, - quantity=0.3159, - tree_id=0, - level=0, - lft=0, rght=0 + part=si.part, quantity=0.3159, tree_id=0, level=0, lft=0, rght=0 ) self.post( self.url, { - "items": [ - { - "build_line": line.pk, - "stock_item": si.pk, - "quantity": 0.1616, - } + 'items': [ + {'build_line': line.pk, 'stock_item': si.pk, 'quantity': 0.1616} ] }, expected_code=201, ) + class BuildItemTest(BuildAPITest): """Unit tests for build items. @@ -1008,7 +823,7 @@ class BuildItemTest(BuildAPITest): """ def setUp(self): - """Basic operation as part of test suite setup""" + """Basic operation as part of test suite setup.""" super().setUp() self.assignRole('build.add') @@ -1024,7 +839,6 @@ class BuildItemTest(BuildAPITest): def test_update_overallocated(self): """Test update of overallocated stock items.""" - si = StockItem.objects.get(pk=2) # Find line item @@ -1035,11 +849,7 @@ class BuildItemTest(BuildAPITest): si.save() # Create build item - bi = BuildItem( - build_line=line, - stock_item=si, - quantity=100 - ) + bi = BuildItem(build_line=line, stock_item=si, quantity=100) bi.save() # Reduce stock item quantity @@ -1049,13 +859,8 @@ class BuildItemTest(BuildAPITest): # Reduce build item quantity url = reverse('api-build-item-detail', kwargs={'pk': bi.pk}) - self.patch( - url, - { - "quantity": 50, - }, - expected_code=200, - ) + self.patch(url, {'quantity': 50}, expected_code=200) + class BuildOverallocationTest(BuildAPITest): """Unit tests for over allocation of stock items against a build order. @@ -1065,7 +870,7 @@ class BuildOverallocationTest(BuildAPITest): @classmethod def setUpTestData(cls): - """Basic operation as part of test suite setup""" + """Basic operation as part of test suite setup.""" super().setUpTestData() cls.assignRole('build.add') @@ -1089,11 +894,9 @@ class BuildOverallocationTest(BuildAPITest): cls.state[sub_part] = (si, si.quantity, required) - items_to_create.append(BuildItem( - build_line=build_line, - stock_item=si, - quantity=required, - )) + items_to_create.append( + BuildItem(build_line=build_line, stock_item=si, quantity=required) + ) BuildItem.objects.bulk_create(items_to_create) @@ -1103,7 +906,7 @@ class BuildOverallocationTest(BuildAPITest): cls.build.complete_build_output(outputs[0], cls.user) def setUp(self): - """Basic operation as part of test suite setup""" + """Basic operation as part of test suite setup.""" super().setUp() self.generate_exchange_rates() @@ -1117,11 +920,7 @@ class BuildOverallocationTest(BuildAPITest): def test_overallocated_requires_acceptance(self): """Test build order cannot complete with overallocated items.""" # Try to complete the build (it should fail due to overallocation) - response = self.post( - self.url, - {}, - expected_code=400 - ) + response = self.post(self.url, {}, expected_code=400) self.assertIn('accept_overallocated', response.data) # Check stock items have not reduced at all @@ -1132,9 +931,7 @@ class BuildOverallocationTest(BuildAPITest): # Accept overallocated stock self.post( self.url, - { - 'accept_overallocated': 'accept', - }, + {'accept_overallocated': 'accept'}, expected_code=201, max_query_count=1000, # TODO: Come back and refactor this ) @@ -1153,9 +950,7 @@ class BuildOverallocationTest(BuildAPITest): """Test build order will trim/de-allocate overallocated stock when requested.""" self.post( self.url, - { - 'accept_overallocated': 'trim', - }, + {'accept_overallocated': 'trim'}, expected_code=201, max_query_count=1000, # TODO: Come back and refactor this ) @@ -1169,7 +964,6 @@ class BuildOverallocationTest(BuildAPITest): # Check stock items have reduced only by bom requirement (overallocation trimmed) for line in self.build.build_lines.all(): - si, oq, _ = self.state[line.bom_item.sub_part] rq = line.quantity si.refresh_from_db() @@ -1207,11 +1001,11 @@ class BuildListTest(BuildAPITest): Build.objects.create( part=part, - reference="BO-0006", + reference='BO-0006', quantity=10, title='Just some thing', status=BuildStatus.PRODUCTION.value, - target_date=in_the_past + target_date=in_the_past, ) response = self.get(self.url, data={'overdue': True}) @@ -1233,24 +1027,22 @@ class BuildListTest(BuildAPITest): Build.objects.create( part=part, quantity=10, - reference=f"BO-{i + 10}", - title=f"Sub build {i}", - parent=parent + reference=f'BO-{i + 10}', + title=f'Sub build {i}', + parent=parent, ) # And some sub-sub builds for ii, sub_build in enumerate(Build.objects.filter(parent=parent)): - for i in range(3): - x = ii * 10 + i + 50 Build.objects.create( part=part, - reference=f"BO-{x}", - title=f"{sub_build.reference}-00{i}-sub", + reference=f'BO-{x}', + title=f'{sub_build.reference}-00{i}-sub', quantity=40, - parent=sub_build + parent=sub_build, ) # 20 new builds should have been created! @@ -1278,7 +1070,6 @@ class BuildOutputCreateTest(BuildAPITest): def test_create_serialized_output(self): """Create a serialized build output via the API.""" - build_id = 1 url = reverse('api-build-output-create', kwargs={'pk': build_id}) @@ -1291,15 +1082,13 @@ class BuildOutputCreateTest(BuildAPITest): # Post with invalid data response = self.post( - url, - data={ - 'quantity': 10, - 'serial_numbers': '1-100', - }, - expected_code=400 + url, data={'quantity': 10, 'serial_numbers': '1-100'}, expected_code=400 ) - self.assertIn('Group range 1-100 exceeds allowed quantity (10)', str(response.data['serial_numbers'])) + self.assertIn( + 'Group range 1-100 exceeds allowed quantity (10)', + str(response.data['serial_numbers']), + ) # Build outputs have not increased self.assertEqual(n_outputs, build.output_count) @@ -1308,12 +1097,7 @@ class BuildOutputCreateTest(BuildAPITest): self.assertEqual(n_items, part.stock_items.count()) response = self.post( - url, - data={ - 'quantity': 5, - 'serial_numbers': '1,2,3-5', - }, - expected_code=201 + url, data={'quantity': 5, 'serial_numbers': '1,2,3-5'}, expected_code=201 ) # Build outputs have incdeased @@ -1328,7 +1112,6 @@ class BuildOutputCreateTest(BuildAPITest): def test_create_unserialized_output(self): """Create an unserialized build output via the API.""" - build_id = 1 url = reverse('api-build-output-create', kwargs={'pk': build_id}) @@ -1339,13 +1122,7 @@ class BuildOutputCreateTest(BuildAPITest): n_items = part.stock_items.count() # Create a single new output - self.post( - url, - data={ - 'quantity': 10, - }, - expected_code=201 - ) + self.post(url, data={'quantity': 10}, expected_code=201) # Build outputs have increased self.assertEqual(n_outputs + 1, build.output_count) @@ -1353,11 +1130,12 @@ class BuildOutputCreateTest(BuildAPITest): # Stock items have increased self.assertEqual(n_items + 1, part.stock_items.count()) + class BuildOutputScrapTest(BuildAPITest): - """Unit tests for scrapping build outputs""" + """Unit tests for scrapping build outputs.""" def scrap(self, build_id, data, expected_code=None): - """Helper method to POST to the scrap API""" + """Helper method to POST to the scrap API.""" url = reverse('api-build-output-scrap', kwargs={'pk': build_id}) response = self.post(url, data, expected_code=expected_code) @@ -1365,7 +1143,7 @@ class BuildOutputScrapTest(BuildAPITest): return response.data def test_invalid_scraps(self): - """Test that invalid scrap attempts are rejected""" + """Test that invalid scrap attempts are rejected.""" # Test with missing required fields response = self.scrap(1, {}, expected_code=400) @@ -1373,30 +1151,15 @@ class BuildOutputScrapTest(BuildAPITest): self.assertIn('This field is required', str(response[field])) # Scrap with no outputs specified - response = self.scrap( - 1, - { - 'outputs': [], - 'location': 1, - 'notes': 'Should fail', - } - ) + response = self.scrap(1, {'outputs': [], 'location': 1, 'notes': 'Should fail'}) self.assertIn('A list of build outputs must be provided', str(response)) # Scrap with an invalid output ID response = self.scrap( 1, - { - 'outputs': [ - { - 'output': 9999, - } - ], - 'location': 1, - 'notes': 'Should fail', - }, - expected_code=400 + {'outputs': [{'output': 9999}], 'location': 1, 'notes': 'Should fail'}, + expected_code=400, ) self.assertIn('object does not exist', str(response['outputs'])) @@ -1413,22 +1176,16 @@ class BuildOutputScrapTest(BuildAPITest): response = self.scrap( 1, - { - 'outputs': [ - { - 'output': output.pk, - }, - ], - 'location': 1, - 'notes': 'Should fail', - }, - expected_code=400 + {'outputs': [{'output': output.pk}], 'location': 1, 'notes': 'Should fail'}, + expected_code=400, ) - self.assertIn("Build output does not match the parent build", str(response['outputs'])) + self.assertIn( + 'Build output does not match the parent build', str(response['outputs']) + ) def test_valid_scraps(self): - """Test that valid scrap attempts succeed""" + """Test that valid scrap attempts succeed.""" # Create a build output build = Build.objects.get(pk=1) @@ -1449,23 +1206,14 @@ class BuildOutputScrapTest(BuildAPITest): 1, { 'outputs': [ - { - 'output': outputs[0].pk, - 'quantity': outputs[0].quantity, - }, - { - 'output': outputs[1].pk, - 'quantity': outputs[1].quantity, - }, - { - 'output': outputs[2].pk, - 'quantity': outputs[2].quantity, - }, + {'output': outputs[0].pk, 'quantity': outputs[0].quantity}, + {'output': outputs[1].pk, 'quantity': outputs[1].quantity}, + {'output': outputs[2].pk, 'quantity': outputs[2].quantity}, ], 'location': 1, 'notes': 'Should succeed', }, - expected_code=201 + expected_code=201, ) # There should still be three outputs associated with this build @@ -1482,7 +1230,6 @@ class BuildLineTests(BuildAPITest): def test_filter_available(self): """Filter BuildLine objects by 'available' status.""" - url = reverse('api-build-line-list') # First *all* BuildLine objects diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py index dcc0c37f37..20f3b25403 100644 --- a/src/backend/InvenTree/build/test_build.py +++ b/src/backend/InvenTree/build/test_build.py @@ -1,38 +1,34 @@ -"""Unit tests for the 'build' models""" +"""Unit tests for the 'build' models.""" + +import logging import uuid from datetime import datetime, timedelta -from django.test import TestCase - -from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core.exceptions import ValidationError from django.db.models import Sum +from django.test import TestCase from django.test.utils import override_settings -from InvenTree import status_codes as status -from InvenTree.unit_test import findOffloadedEvent - -import common.models -from common.settings import set_global_setting import build.tasks +import common.models from build.models import Build, BuildItem, BuildLine, generate_next_build_reference from build.status_codes import BuildStatus -from part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate +from common.settings import set_global_setting +from InvenTree import status_codes as status +from InvenTree.unit_test import findOffloadedEvent +from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate from stock.models import StockItem, StockItemTestResult from users.models import Owner -import logging logger = logging.getLogger('inventree') class BuildTestBase(TestCase): """Run some tests to ensure that the Build model is working properly.""" - fixtures = [ - 'users', - ] + fixtures = ['users'] @classmethod def setUpTestData(cls): @@ -54,8 +50,8 @@ class BuildTestBase(TestCase): # Create a base "Part" cls.assembly = Part.objects.create( - name="An assembled part", - description="Why does it matter what my description is?", + name='An assembled part', + description='Why does it matter what my description is?', assembly=True, trackable=True, testable=True, @@ -63,8 +59,8 @@ class BuildTestBase(TestCase): # create one build with one required test template cls.tested_part_with_required_test = Part.objects.create( - name="Part having required tests", - description="Why does it matter what my description is?", + name='Part having required tests', + description='Why does it matter what my description is?', assembly=True, trackable=True, testable=True, @@ -72,18 +68,18 @@ class BuildTestBase(TestCase): cls.test_template_required = PartTestTemplate.objects.create( part=cls.tested_part_with_required_test, - test_name="Required test", - description="Required test template description", + test_name='Required test', + description='Required test template description', required=True, requires_value=False, - requires_attachment=False + requires_attachment=False, ) ref = generate_next_build_reference() cls.build_w_tests_trackable = Build.objects.create( reference=ref, - title="This is a build", + title='This is a build', part=cls.tested_part_with_required_test, quantity=1, issued_by=get_user_model().objects.get(pk=1), @@ -94,13 +90,13 @@ class BuildTestBase(TestCase): quantity=1, is_building=True, serial=uuid.uuid4(), - build=cls.build_w_tests_trackable + build=cls.build_w_tests_trackable, ) # now create a part with a non-required test template cls.tested_part_wo_required_test = Part.objects.create( - name="Part with one non.required test", - description="Why does it matter what my description is?", + name='Part with one non.required test', + description='Why does it matter what my description is?', assembly=True, trackable=True, testable=True, @@ -108,18 +104,18 @@ class BuildTestBase(TestCase): cls.test_template_non_required = PartTestTemplate.objects.create( part=cls.tested_part_wo_required_test, - test_name="Required test template", - description="Required test template description", + test_name='Required test template', + description='Required test template description', required=False, requires_value=False, - requires_attachment=False + requires_attachment=False, ) ref = generate_next_build_reference() cls.build_wo_tests_trackable = Build.objects.create( reference=ref, - title="This is a build", + title='This is a build', part=cls.tested_part_wo_required_test, quantity=1, issued_by=get_user_model().objects.get(pk=1), @@ -130,47 +126,33 @@ class BuildTestBase(TestCase): quantity=1, is_building=True, serial=uuid.uuid4(), - build=cls.build_wo_tests_trackable + build=cls.build_wo_tests_trackable, ) cls.sub_part_1 = Part.objects.create( - name="Widget A", - description="A widget", - component=True + name='Widget A', description='A widget', component=True ) cls.sub_part_2 = Part.objects.create( - name="Widget B", - description="A widget", - component=True + name='Widget B', description='A widget', component=True ) cls.sub_part_3 = Part.objects.create( - name="Widget C", - description="A widget", - component=True, - trackable=True + name='Widget C', description='A widget', component=True, trackable=True ) # Create BOM item links for the parts cls.bom_item_1 = BomItem.objects.create( - part=cls.assembly, - sub_part=cls.sub_part_1, - quantity=5 + part=cls.assembly, sub_part=cls.sub_part_1, quantity=5 ) cls.bom_item_2 = BomItem.objects.create( - part=cls.assembly, - sub_part=cls.sub_part_2, - quantity=3, - optional=True + part=cls.assembly, sub_part=cls.sub_part_2, quantity=3, optional=True ) # sub_part_3 is trackable! cls.bom_item_3 = BomItem.objects.create( - part=cls.assembly, - sub_part=cls.sub_part_3, - quantity=2 + part=cls.assembly, sub_part=cls.sub_part_3, quantity=2 ) ref = generate_next_build_reference() @@ -178,7 +160,7 @@ class BuildTestBase(TestCase): # Create a "Build" object to make 10x objects cls.build = Build.objects.create( reference=ref, - title="This is a build", + title='This is a build', part=cls.assembly, quantity=10, issued_by=get_user_model().objects.get(pk=1), @@ -192,17 +174,11 @@ class BuildTestBase(TestCase): # Create some build output (StockItem) objects cls.output_1 = StockItem.objects.create( - part=cls.assembly, - quantity=3, - is_building=True, - build=cls.build + part=cls.assembly, quantity=3, is_building=True, build=cls.build ) cls.output_2 = StockItem.objects.create( - part=cls.assembly, - quantity=7, - is_building=True, - build=cls.build, + part=cls.assembly, quantity=7, is_building=True, build=cls.build ) # Create some stock items to assign to the build @@ -219,12 +195,14 @@ class BuildTestBase(TestCase): class BuildTest(BuildTestBase): - """Unit testing class for the Build model""" + """Unit testing class for the Build model.""" def test_ref_int(self): - """Test the "integer reference" field used for natural sorting""" + """Test the "integer reference" field used for natural sorting.""" # Set build reference to new value - set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None) + set_global_setting( + 'BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None + ) refs = { 'BO-123-456': 123, @@ -236,10 +214,7 @@ class BuildTest(BuildTestBase): for ref, ref_int in refs.items(): build = Build( - reference=ref, - quantity=1, - part=self.assembly, - title='Making some parts', + reference=ref, quantity=1, part=self.assembly, title='Making some parts' ) self.assertEqual(build.reference_int, 0) @@ -247,18 +222,17 @@ class BuildTest(BuildTestBase): self.assertEqual(build.reference_int, ref_int) # Set build reference back to default value - set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None) + set_global_setting( + 'BUILDORDER_REFERENCE_PATTERN', + 'BO-{ref:04d}', # noqa: RUF027 + change_user=None, + ) def test_ref_validation(self): - """Test that the reference field validation works as expected""" + """Test that the reference field validation works as expected.""" # Default reference pattern = 'BO-{ref:04d} # These patterns should fail - for ref in [ - 'BO-1234x', - 'BO1234', - 'OB-1234', - 'BO--1234' - ]: + for ref in ['BO-1234x', 'BO1234', 'OB-1234', 'BO--1234']: with self.assertRaises(ValidationError): Build.objects.create( part=self.assembly, @@ -267,63 +241,53 @@ class BuildTest(BuildTestBase): title='Invalid reference', ) - for ref in [ - 'BO-1234', - 'BO-9999', - 'BO-123' - ]: + for ref in ['BO-1234', 'BO-9999', 'BO-123']: Build.objects.create( - part=self.assembly, - quantity=10, - reference=ref, - title='Valid reference', + part=self.assembly, quantity=10, reference=ref, title='Valid reference' ) # Try a new validator pattern - set_global_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None) + set_global_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None) # noqa: RUF027 - for ref in [ - '1234-BO', - '9999-BO' - ]: + for ref in ['1234-BO', '9999-BO']: Build.objects.create( - part=self.assembly, - quantity=10, - reference=ref, - title='Valid reference', + part=self.assembly, quantity=10, reference=ref, title='Valid reference' ) # Set build reference back to default value - set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None) + set_global_setting( + 'BUILDORDER_REFERENCE_PATTERN', + 'BO-{ref:04d}', # noqa: RUF027 + change_user=None, + ) def test_next_ref(self): - """Test that the next reference is automatically generated""" - set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None) + """Test that the next reference is automatically generated.""" + set_global_setting( + 'BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None + ) build = Build.objects.create( - part=self.assembly, - quantity=5, - reference='XYZ-987', - title='Some thing', + part=self.assembly, quantity=5, reference='XYZ-987', title='Some thing' ) self.assertEqual(build.reference_int, 987) # Now create one *without* specifying the reference build = Build.objects.create( - part=self.assembly, - quantity=1, - title='Some new title', + part=self.assembly, quantity=1, title='Some new title' ) self.assertEqual(build.reference, 'XYZ-000988') self.assertEqual(build.reference_int, 988) # Set build reference back to default value - set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None) + set_global_setting( + 'BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None + ) def test_init(self): - """Perform some basic tests before we start the ball rolling""" + """Perform some basic tests before we start the ball rolling.""" self.assertEqual(StockItem.objects.count(), 12) # Build is PENDING @@ -348,7 +312,7 @@ class BuildTest(BuildTestBase): self.assertFalse(self.build.is_complete) def test_build_item_clean(self): - """Ensure that dodgy BuildItem objects cannot be created""" + """Ensure that dodgy BuildItem objects cannot be created.""" stock = StockItem.objects.create(part=self.assembly, quantity=99) # Create a BuiltItem which points to an invalid StockItem @@ -358,7 +322,9 @@ class BuildTest(BuildTestBase): b.save() # Create a BuildItem which has too much stock assigned - b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999) + b = BuildItem( + stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999 + ) with self.assertRaises(ValidationError): b.clean() @@ -370,19 +336,22 @@ class BuildTest(BuildTestBase): b.clean() # Ok, what about we make one that does *not* fail? - b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10) + b = BuildItem( + stock_item=self.stock_1_2, + build_line=self.line_1, + install_into=self.output_1, + quantity=10, + ) b.save() def test_duplicate_bom_line(self): - """Try to add a duplicate BOM item - it should be allowed""" + """Try to add a duplicate BOM item - it should be allowed.""" BomItem.objects.create( - part=self.assembly, - sub_part=self.sub_part_1, - quantity=99 + part=self.assembly, sub_part=self.sub_part_1, quantity=99 ) def allocate_stock(self, output, allocations): - """Allocate stock to this build, against a particular output + """Allocate stock to this build, against a particular output. Args: output: StockItem object (or None) @@ -391,52 +360,36 @@ class BuildTest(BuildTestBase): items_to_create = [] for item, quantity in allocations.items(): - # Find an appropriate BuildLine to allocate against line = BuildLine.objects.filter( - build=self.build, - bom_item__sub_part=item.part + build=self.build, bom_item__sub_part=item.part ).first() - items_to_create.append(BuildItem( - build_line=line, - stock_item=item, - quantity=quantity, - install_into=output - )) + items_to_create.append( + BuildItem( + build_line=line, + stock_item=item, + quantity=quantity, + install_into=output, + ) + ) BuildItem.objects.bulk_create(items_to_create) def test_partial_allocation(self): - """Test partial allocation of stock""" + """Test partial allocation of stock.""" # Fully allocate tracked stock against build output 1 - self.allocate_stock( - self.output_1, - { - self.stock_3_1: 6, - } - ) + self.allocate_stock(self.output_1, {self.stock_3_1: 6}) self.assertTrue(self.build.is_output_fully_allocated(self.output_1)) # Partially allocate tracked stock against build output 2 - self.allocate_stock( - self.output_2, - { - self.stock_3_1: 1, - } - ) + self.allocate_stock(self.output_2, {self.stock_3_1: 1}) self.assertFalse(self.build.is_output_fully_allocated(self.output_2)) # Partially allocate untracked stock against build - self.allocate_stock( - None, - { - self.stock_1_1: 1, - self.stock_2_1: 1 - } - ) + self.allocate_stock(None, {self.stock_1_1: 1, self.stock_2_1: 1}) self.assertFalse(self.build.is_output_fully_allocated(None)) @@ -445,12 +398,7 @@ class BuildTest(BuildTestBase): self.assertEqual(len(unallocated), 3) - self.allocate_stock( - None, - { - self.stock_1_2: 100, - } - ) + self.allocate_stock(None, {self.stock_1_2: 100}) self.assertFalse(self.build.is_fully_allocated(None)) @@ -470,44 +418,21 @@ class BuildTest(BuildTestBase): self.stock_2_1.save() # Now we "fully" allocate the untracked untracked items - self.allocate_stock( - None, - { - self.stock_1_2: 50, - self.stock_2_1: 50, - } - ) + self.allocate_stock(None, {self.stock_1_2: 50, self.stock_2_1: 50}) self.assertTrue(self.build.is_fully_allocated(tracked=False)) def test_overallocation_and_trim(self): - """Test overallocation of stock and trim function""" - + """Test overallocation of stock and trim function.""" self.assertEqual(self.build.status, status.BuildStatus.PENDING) self.build.issue_build() self.assertEqual(self.build.status, status.BuildStatus.PRODUCTION) # Fully allocate tracked stock (not eligible for trimming) - self.allocate_stock( - self.output_1, - { - self.stock_3_1: 6, - } - ) - self.allocate_stock( - self.output_2, - { - self.stock_3_1: 14, - } - ) + self.allocate_stock(self.output_1, {self.stock_3_1: 6}) + self.allocate_stock(self.output_2, {self.stock_3_1: 14}) # Fully allocate part 1 (should be left alone) - self.allocate_stock( - None, - { - self.stock_1_1: 3, - self.stock_1_2: 47, - } - ) + self.allocate_stock(None, {self.stock_1_1: 3, self.stock_1_2: 47}) extra_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=6) extra_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=4) @@ -521,9 +446,9 @@ class BuildTest(BuildTestBase): self.stock_2_3: 5, self.stock_2_4: 5, self.stock_2_5: 5, # 25 - extra_2_1: 6, # 31 - extra_2_2: 4, # 35 - } + extra_2_1: 6, # 31 + extra_2_2: 4, # 35 + }, ) self.assertTrue(self.build.is_overallocated()) @@ -550,19 +475,28 @@ class BuildTest(BuildTestBase): self.assertEqual(items.aggregate(Sum('quantity'))['quantity__sum'], 35) # However, the "available" stock quantity has been decreased - self.assertEqual(items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'], 5) + self.assertEqual( + items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'], + 5, + ) # And the "consumed_by" quantity has been increased - self.assertEqual(items.filter(consumed_by=self.build).aggregate(Sum('quantity'))['quantity__sum'], 30) + self.assertEqual( + items.filter(consumed_by=self.build).aggregate(Sum('quantity'))[ + 'quantity__sum' + ], + 30, + ) self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980) # Check that the "consumed_by" item count has increased - self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), n + 8) + self.assertEqual( + StockItem.objects.filter(consumed_by=self.build).count(), n + 8 + ) def test_change_part(self): - """Try to change target part after creating a build""" - + """Try to change target part after creating a build.""" bo = Build.objects.create( reference='BO-9999', title='Some new build', @@ -572,9 +506,7 @@ class BuildTest(BuildTestBase): ) assembly_2 = Part.objects.create( - name="Another assembly", - description="A different assembly", - assembly=True, + name='Another assembly', description='A different assembly', assembly=True ) # Should not be able to change the part after the Build is saved @@ -583,7 +515,7 @@ class BuildTest(BuildTestBase): bo.clean() def test_cancel(self): - """Test cancellation of the build""" + """Test cancellation of the build.""" # TODO """ self.allocate_stock(50, 50, 200, self.output_1) @@ -591,10 +523,9 @@ class BuildTest(BuildTestBase): self.assertEqual(BuildItem.objects.count(), 0) """ - pass def test_complete(self): - """Test completion of a build output""" + """Test completion of a build output.""" self.stock_1_1.quantity = 1000 self.stock_1_1.save() @@ -609,25 +540,15 @@ class BuildTest(BuildTestBase): { self.stock_1_1: self.stock_1_1.quantity, # Allocate *all* stock from this item self.stock_1_2: 10, - self.stock_2_1: 30 - } + self.stock_2_1: 30, + }, ) # Allocate tracked parts to output_1 - self.allocate_stock( - self.output_1, - { - self.stock_3_1: 6 - } - ) + self.allocate_stock(self.output_1, {self.stock_3_1: 6}) # Allocate tracked parts to output_2 - self.allocate_stock( - self.output_2, - { - self.stock_3_1: 14 - } - ) + self.allocate_stock(self.output_2, {self.stock_3_1: 14}) self.assertTrue(self.build.is_fully_allocated(None)) self.assertTrue(self.build.is_fully_allocated(self.output_1)) @@ -665,25 +586,32 @@ class BuildTest(BuildTestBase): self.assertFalse(output.is_building) def test_complete_with_required_tests(self): - """Test the prevention completion when a required test is missing feature""" - + """Test the prevention completion when a required test is missing feature.""" # with required tests incompleted the save should fail - set_global_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None) + set_global_setting( + 'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None + ) with self.assertRaises(ValidationError): - self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None) + self.build_w_tests_trackable.complete_build_output( + self.stockitem_with_required_test, None + ) # let's complete the required test and see if it could be saved StockItemTestResult.objects.create( stock_item=self.stockitem_with_required_test, template=self.test_template_required, - result=True + result=True, ) - self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None) + self.build_w_tests_trackable.complete_build_output( + self.stockitem_with_required_test, None + ) # let's see if a non required test could be saved - self.build_wo_tests_trackable.complete_build_output(self.stockitem_wo_required_test, None) + self.build_wo_tests_trackable.complete_build_output( + self.stockitem_wo_required_test, None + ) def test_overdue_notification(self): """Test sending of notifications when a build order is overdue.""" @@ -694,26 +622,25 @@ class BuildTest(BuildTestBase): build.tasks.check_overdue_build_orders() message = common.models.NotificationMessage.objects.get( - category='build.overdue_build_order', - user__id=1, + category='build.overdue_build_order', user__id=1 ) self.assertEqual(message.name, 'Overdue Build Order') def test_new_build_notification(self): - """Test that a notification is sent when a new build is created""" + """Test that a notification is sent when a new build is created.""" Build.objects.create( reference='BO-9999', title='Some new build', part=self.assembly, quantity=5, issued_by=get_user_model().objects.get(pk=2), - responsible=Owner.create(obj=Group.objects.get(pk=3)) + responsible=Owner.create(obj=Group.objects.get(pk=3)), ) # Two notifications should have been sent messages = common.models.NotificationMessage.objects.filter( - category='build.new_build', + category='build.new_build' ) self.assertEqual(messages.count(), 1) @@ -728,12 +655,12 @@ class BuildTest(BuildTestBase): @override_settings( TESTING_TABLE_EVENTS=True, PLUGIN_TESTING_EVENTS=True, - PLUGIN_TESTING_EVENTS_ASYNC=True + PLUGIN_TESTING_EVENTS_ASYNC=True, ) def test_events(self): """Test that build events are triggered correctly.""" - from django_q.models import OrmQ + from build.events import BuildEvents set_global_setting('ENABLE_PLUGINS_EVENTS', True) @@ -747,7 +674,7 @@ class BuildTest(BuildTestBase): part=self.assembly, quantity=5, issued_by=get_user_model().objects.get(pk=2), - responsible=Owner.create(obj=Group.objects.get(pk=3)) + responsible=Owner.create(obj=Group.objects.get(pk=3)), ) # Check that the 'build.created' event was triggered @@ -769,9 +696,7 @@ class BuildTest(BuildTestBase): # Check that the 'build.issued' event was triggered task = findOffloadedEvent( - BuildEvents.ISSUED, - matching_kwargs=['id'], - clear_after=True, + BuildEvents.ISSUED, matching_kwargs=['id'], clear_after=True ) self.assertIsNotNone(task) @@ -781,7 +706,12 @@ class BuildTest(BuildTestBase): def test_metadata(self): """Unit tests for the metadata field.""" # Make sure a BuildItem exists before trying to run this test - b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10) + b = BuildItem( + stock_item=self.stock_1_2, + build_line=self.line_1, + install_into=self.output_1, + quantity=10, + ) b.save() for model in [Build, BuildItem]: @@ -802,28 +732,20 @@ class BuildTest(BuildTestBase): class AutoAllocationTests(BuildTestBase): - """Tests for auto allocating stock against a build order""" + """Tests for auto allocating stock against a build order.""" def setUp(self): - """Init routines for this unit test class""" + """Init routines for this unit test class.""" super().setUp() # Add a "substitute" part for bom_item_2 alt_part = Part.objects.create( - name="alt part", - description="An alternative part!", - component=True, + name='alt part', description='An alternative part!', component=True ) - BomItemSubstitute.objects.create( - bom_item=self.bom_item_2, - part=alt_part, - ) + BomItemSubstitute.objects.create(bom_item=self.bom_item_2, part=alt_part) - StockItem.objects.create( - part=alt_part, - quantity=500, - ) + StockItem.objects.create(part=alt_part, quantity=500) def test_auto_allocate(self): """Run the 'auto-allocate' function. What do we expect to happen? @@ -840,10 +762,7 @@ class AutoAllocationTests(BuildTestBase): self.assertFalse(self.build.is_fully_allocated(tracked=False)) # Stock is not interchangeable, nothing will happen - self.build.auto_allocate_stock( - interchangeable=False, - substitutes=False, - ) + self.build.auto_allocate_stock(interchangeable=False, substitutes=False) self.assertFalse(self.build.is_fully_allocated(tracked=False)) @@ -857,9 +776,7 @@ class AutoAllocationTests(BuildTestBase): # This time we expect stock to be allocated! self.build.auto_allocate_stock( - interchangeable=True, - substitutes=False, - optional_items=True, + interchangeable=True, substitutes=False, optional_items=True ) self.assertFalse(self.build.is_fully_allocated(tracked=False)) @@ -873,10 +790,7 @@ class AutoAllocationTests(BuildTestBase): self.assertEqual(self.line_2.unallocated_quantity(), 5) # This time, allow substitute parts to be used! - self.build.auto_allocate_stock( - interchangeable=True, - substitutes=True, - ) + self.build.auto_allocate_stock(interchangeable=True, substitutes=True) self.assertEqual(self.line_1.unallocated_quantity(), 0) self.assertEqual(self.line_2.unallocated_quantity(), 5) @@ -885,11 +799,9 @@ class AutoAllocationTests(BuildTestBase): self.assertFalse(self.line_2.is_fully_allocated()) def test_fully_auto(self): - """We should be able to auto-allocate against a build in a single go""" + """We should be able to auto-allocate against a build in a single go.""" self.build.auto_allocate_stock( - interchangeable=True, - substitutes=True, - optional_items=True, + interchangeable=True, substitutes=True, optional_items=True ) self.assertTrue(self.build.is_fully_allocated(tracked=False)) diff --git a/src/backend/InvenTree/build/test_migrations.py b/src/backend/InvenTree/build/test_migrations.py index 4a0c720e5d..924b45d249 100644 --- a/src/backend/InvenTree/build/test_migrations.py +++ b/src/backend/InvenTree/build/test_migrations.py @@ -16,21 +16,17 @@ class TestForwardMigrations(MigratorTestCase): Part = self.old_state.apps.get_model('part', 'part') buildable_part = Part.objects.create( - name='Widget', - description='Buildable Part', - active=True, + name='Widget', description='Buildable Part', active=True ) Build = self.old_state.apps.get_model('build', 'build') Build.objects.create( - part=buildable_part, - title='A build of some stuff', - quantity=50, + part=buildable_part, title='A build of some stuff', quantity=50 ) def test_items_exist(self): - """Test to ensure that the 'assembly' field is correctly configured""" + """Test to ensure that the 'assembly' field is correctly configured.""" Part = self.new_state.apps.get_model('part', 'part') self.assertEqual(Part.objects.count(), 1) @@ -57,30 +53,15 @@ class TestReferenceMigration(MigratorTestCase): """Create some builds.""" Part = self.old_state.apps.get_model('part', 'part') - part = Part.objects.create( - name='Part', - description='A test part', - ) + part = Part.objects.create(name='Part', description='A test part') Build = self.old_state.apps.get_model('build', 'build') - Build.objects.create( - part=part, - title='My very first build', - quantity=10 - ) + Build.objects.create(part=part, title='My very first build', quantity=10) - Build.objects.create( - part=part, - title='My very second build', - quantity=10 - ) + Build.objects.create(part=part, title='My very second build', quantity=10) - Build.objects.create( - part=part, - title='My very third build', - quantity=10 - ) + Build.objects.create(part=part, title='My very third build', quantity=10) # Ensure that the builds *do not* have a 'reference' field for build in Build.objects.all(): @@ -88,7 +69,7 @@ class TestReferenceMigration(MigratorTestCase): print(build.reference) def test_build_reference(self): - """Test that the build reference is correctly assigned to the PK of the Build""" + """Test that the build reference is correctly assigned to the PK of the Build.""" Build = self.new_state.apps.get_model('build', 'build') self.assertEqual(Build.objects.count(), 3) @@ -108,21 +89,16 @@ class TestReferencePatternMigration(MigratorTestCase): migrate_to = ('build', unit_test.getNewestMigrationFile('build')) def prepare(self): - """Create some initial data prior to migration""" + """Create some initial data prior to migration.""" Setting = self.old_state.apps.get_model('common', 'inventreesetting') # Create a custom existing prefix so we can confirm the operation is working - Setting.objects.create( - key='BUILDORDER_REFERENCE_PREFIX', - value='BuildOrder-', - ) + Setting.objects.create(key='BUILDORDER_REFERENCE_PREFIX', value='BuildOrder-') Part = self.old_state.apps.get_model('part', 'part') assembly = Part.objects.create( - name='Assy 1', - description='An assembly', - level=0, lft=0, rght=0, tree_id=0, + name='Assy 1', description='An assembly', level=0, lft=0, rght=0, tree_id=0 ) Build = self.old_state.apps.get_model('build', 'build') @@ -130,14 +106,17 @@ class TestReferencePatternMigration(MigratorTestCase): for idx in range(1, 11): Build.objects.create( part=assembly, - title=f"Build {idx}", + title=f'Build {idx}', quantity=idx, - reference=f"{idx + 100}", - level=0, lft=0, rght=0, tree_id=0, + reference=f'{idx + 100}', + level=0, + lft=0, + rght=0, + tree_id=0, ) def test_reference_migration(self): - """Test that the reference fields have been correctly updated""" + """Test that the reference fields have been correctly updated.""" Build = self.new_state.apps.get_model('build', 'build') for build in Build.objects.all(): @@ -165,7 +144,7 @@ class TestBuildLineCreation(MigratorTestCase): migrate_to = ('build', '0047_auto_20230606_1058') def prepare(self): - """Create data to work with""" + """Create data to work with.""" # Model references Part = self.old_state.apps.get_model('part', 'part') BomItem = self.old_state.apps.get_model('part', 'bomitem') @@ -182,40 +161,44 @@ class TestBuildLineCreation(MigratorTestCase): name='Assembly', description='An assembly', assembly=True, - level=0, lft=0, rght=0, tree_id=0, + level=0, + lft=0, + rght=0, + tree_id=0, ) # Create components for idx in range(1, 11): part = Part.objects.create( - name=f"Part {idx}", - description=f"Part {idx}", - level=0, lft=0, rght=0, tree_id=0, + name=f'Part {idx}', + description=f'Part {idx}', + level=0, + lft=0, + rght=0, + tree_id=0, ) # Create plentiful stock StockItem.objects.create( - part=part, - quantity=1000, - level=0, lft=0, rght=0, tree_id=0, + part=part, quantity=1000, level=0, lft=0, rght=0, tree_id=0 ) # Create a BOM item BomItem.objects.create( - part=assembly, - sub_part=part, - quantity=idx, - reference=f"REF-{idx}", + part=assembly, sub_part=part, quantity=idx, reference=f'REF-{idx}' ) # Create some builds for idx in range(1, 4): build = Build.objects.create( part=assembly, - title=f"Build {idx}", + title=f'Build {idx}', quantity=idx * 10, - reference=f"REF-{idx}", - level=0, lft=0, rght=0, tree_id=0, + reference=f'REF-{idx}', + level=0, + lft=0, + rght=0, + tree_id=0, ) # Allocate stock to the build @@ -229,7 +212,7 @@ class TestBuildLineCreation(MigratorTestCase): ) def test_build_line_creation(self): - """Test that the BuildLine objects have been created correctly""" + """Test that the BuildLine objects have been created correctly.""" Build = self.new_state.apps.get_model('build', 'build') BomItem = self.new_state.apps.get_model('part', 'bomitem') BuildLine = self.new_state.apps.get_model('build', 'buildline') @@ -254,10 +237,7 @@ class TestBuildLineCreation(MigratorTestCase): # Check that each BuildItem has been linked to a BuildLine for item in BuildItem.objects.all(): self.assertIsNotNone(item.build_line) - self.assertEqual( - item.stock_item.part, - item.build_line.bom_item.sub_part, - ) + self.assertEqual(item.stock_item.part, item.build_line.bom_item.sub_part) item = BuildItem.objects.first() @@ -273,12 +253,8 @@ class TestBuildLineCreation(MigratorTestCase): for line in BuildLine.objects.all(): # Check that the quantity is correct self.assertEqual( - line.quantity, - line.build.quantity * line.bom_item.quantity, + line.quantity, line.build.quantity * line.bom_item.quantity ) # Check that the linked parts are correct - self.assertEqual( - line.build.part, - line.bom_item.part, - ) + self.assertEqual(line.build.part, line.bom_item.part) diff --git a/src/backend/InvenTree/build/tests.py b/src/backend/InvenTree/build/tests.py index cb8c84086f..fd560a6fad 100644 --- a/src/backend/InvenTree/build/tests.py +++ b/src/backend/InvenTree/build/tests.py @@ -1,39 +1,26 @@ -"""Basic unit tests for the BuildOrder app""" - -from django.core.exceptions import ValidationError -from django.test import tag -from django.urls import reverse +"""Basic unit tests for the BuildOrder app.""" from datetime import datetime, timedelta +from django.core.exceptions import ValidationError + +from build.status_codes import BuildStatus +from common.settings import set_global_setting from InvenTree.unit_test import InvenTreeTestCase +from part.models import BomItem, Part from .models import Build -from part.models import Part, BomItem -from stock.models import StockItem - -from common.settings import set_global_setting -from build.status_codes import BuildStatus class BuildTestSimple(InvenTreeTestCase): - """Basic set of tests for the BuildOrder model functionality""" + """Basic set of tests for the BuildOrder model functionality.""" - fixtures = [ - 'category', - 'part', - 'location', - 'build', - ] + fixtures = ['category', 'part', 'location', 'build'] - roles = [ - 'build.change', - 'build.add', - 'build.delete', - ] + roles = ['build.change', 'build.add', 'build.delete'] def test_build_objects(self): - """Ensure the Build objects were correctly created""" + """Ensure the Build objects were correctly created.""" self.assertEqual(Build.objects.count(), 5) b = Build.objects.get(pk=2) self.assertEqual(b.batch, 'B2') @@ -42,12 +29,12 @@ class BuildTestSimple(InvenTreeTestCase): self.assertEqual(str(b), 'BO-0002') def test_url(self): - """Test URL lookup""" + """Test URL lookup.""" b1 = Build.objects.get(pk=1) self.assertEqual(b1.get_absolute_url(), '/platform/manufacturing/build-order/1') def test_is_complete(self): - """Test build completion status""" + """Test build completion status.""" b1 = Build.objects.get(pk=1) b2 = Build.objects.get(pk=2) @@ -72,7 +59,7 @@ class BuildTestSimple(InvenTreeTestCase): self.assertFalse(build.is_overdue) def test_is_active(self): - """Test active / inactive build status""" + """Test active / inactive build status.""" b1 = Build.objects.get(pk=1) b2 = Build.objects.get(pk=2) @@ -91,7 +78,6 @@ class BuildTestSimple(InvenTreeTestCase): def test_build_create(self): """Test creation of build orders via API.""" - n = Build.objects.count() # Find an assembly part @@ -105,13 +91,9 @@ class BuildTestSimple(InvenTreeTestCase): # Let's create some BOM items for this assembly for component in Part.objects.filter(assembly=False, component=True)[:15]: - try: BomItem.objects.create( - part=assembly, - sub_part=component, - reference='xxx', - quantity=5 + part=assembly, sub_part=component, reference='xxx', quantity=5 ) except ValidationError: pass diff --git a/src/backend/InvenTree/build/validators.py b/src/backend/InvenTree/build/validators.py index 87ebfc25ba..a4213c8750 100644 --- a/src/backend/InvenTree/build/validators.py +++ b/src/backend/InvenTree/build/validators.py @@ -1,15 +1,15 @@ -"""Validation methods for the build app""" +"""Validation methods for the build app.""" def generate_next_build_reference(): - """Generate the next available BuildOrder reference""" + """Generate the next available BuildOrder reference.""" from build.models import Build return Build.generate_reference() def validate_build_order_reference_pattern(pattern): - """Validate the BuildOrder reference 'pattern' setting""" + """Validate the BuildOrder reference 'pattern' setting.""" from build.models import Build Build.validate_reference_pattern(pattern) diff --git a/src/backend/InvenTree/common/admin.py b/src/backend/InvenTree/common/admin.py index a2b02522db..1326085082 100644 --- a/src/backend/InvenTree/common/admin.py +++ b/src/backend/InvenTree/common/admin.py @@ -2,8 +2,6 @@ from django.contrib import admin -from import_export.admin import ImportExportModelAdmin - import common.models import common.validators @@ -45,7 +43,7 @@ class BarcodeScanResultAdmin(admin.ModelAdmin): @admin.register(common.models.ProjectCode) -class ProjectCodeAdmin(ImportExportModelAdmin): +class ProjectCodeAdmin(admin.ModelAdmin): """Admin settings for ProjectCode.""" list_display = ('code', 'description') @@ -54,7 +52,7 @@ class ProjectCodeAdmin(ImportExportModelAdmin): @admin.register(common.models.InvenTreeSetting) -class SettingsAdmin(ImportExportModelAdmin): +class SettingsAdmin(admin.ModelAdmin): """Admin settings for InvenTreeSetting.""" list_display = ('key', 'value') @@ -67,7 +65,7 @@ class SettingsAdmin(ImportExportModelAdmin): @admin.register(common.models.InvenTreeUserSetting) -class UserSettingsAdmin(ImportExportModelAdmin): +class UserSettingsAdmin(admin.ModelAdmin): """Admin settings for InvenTreeUserSetting.""" list_display = ('key', 'value', 'user') @@ -80,7 +78,7 @@ class UserSettingsAdmin(ImportExportModelAdmin): @admin.register(common.models.WebhookEndpoint) -class WebhookAdmin(ImportExportModelAdmin): +class WebhookAdmin(admin.ModelAdmin): """Admin settings for Webhook.""" list_display = ('endpoint_id', 'name', 'active', 'user') @@ -119,4 +117,4 @@ class NewsFeedEntryAdmin(admin.ModelAdmin): list_display = ('title', 'author', 'published', 'summary') -admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin) +admin.site.register(common.models.WebhookMessage, admin.ModelAdmin) diff --git a/src/backend/InvenTree/company/admin.py b/src/backend/InvenTree/company/admin.py index ae33a409ba..64610709af 100644 --- a/src/backend/InvenTree/company/admin.py +++ b/src/backend/InvenTree/company/admin.py @@ -2,14 +2,7 @@ from django.contrib import admin -from import_export import widgets -from import_export.admin import ImportExportModelAdmin -from import_export.fields import Field - import company.serializers -import importer.admin -from InvenTree.admin import InvenTreeResource -from part.models import Part from .models import ( Address, @@ -22,50 +15,17 @@ from .models import ( ) -class CompanyResource(InvenTreeResource): - """Class for managing Company data import/export.""" - - class Meta: - """Metaclass defines extra options.""" - - model = Company - skip_unchanged = True - report_skipped = False - clean_model_instances = True - - @admin.register(Company) -class CompanyAdmin(importer.admin.DataExportAdmin, ImportExportModelAdmin): +class CompanyAdmin(admin.ModelAdmin): """Admin class for the Company model.""" serializer_class = company.serializers.CompanySerializer - resource_class = CompanyResource list_display = ('name', 'website', 'contact') search_fields = ['name', 'description'] -class SupplierPartResource(InvenTreeResource): - """Class for managing SupplierPart data import/export.""" - - class Meta: - """Metaclass defines extra admin options.""" - - model = SupplierPart - skip_unchanged = True - report_skipped = True - clean_model_instances = True - - part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) - - part_name = Field(attribute='part__full_name', readonly=True) - - supplier = Field(attribute='supplier', widget=widgets.ForeignKeyWidget(Company)) - - supplier_name = Field(attribute='supplier__name', readonly=True) - - class SupplierPriceBreakInline(admin.TabularInline): """Inline for supplier-part pricing.""" @@ -73,11 +33,9 @@ class SupplierPriceBreakInline(admin.TabularInline): @admin.register(SupplierPart) -class SupplierPartAdmin(ImportExportModelAdmin): +class SupplierPartAdmin(admin.ModelAdmin): """Admin class for the SupplierPart model.""" - resource_class = SupplierPartResource - list_display = ('part', 'supplier', 'SKU') search_fields = ['supplier__name', 'part__name', 'manufacturer_part__MPN', 'SKU'] @@ -87,34 +45,10 @@ class SupplierPartAdmin(ImportExportModelAdmin): autocomplete_fields = ('part', 'supplier', 'manufacturer_part') -class ManufacturerPartResource(InvenTreeResource): - """Class for managing ManufacturerPart data import/export.""" - - class Meta: - """Metaclass defines extra admin options.""" - - model = ManufacturerPart - skip_unchanged = True - report_skipped = True - clean_model_instances = True - - part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) - - part_name = Field(attribute='part__full_name', readonly=True) - - manufacturer = Field( - attribute='manufacturer', widget=widgets.ForeignKeyWidget(Company) - ) - - manufacturer_name = Field(attribute='manufacturer__name', readonly=True) - - @admin.register(ManufacturerPart) -class ManufacturerPartAdmin(ImportExportModelAdmin): +class ManufacturerPartAdmin(admin.ModelAdmin): """Admin class for ManufacturerPart model.""" - resource_class = ManufacturerPartResource - list_display = ('part', 'manufacturer', 'MPN') search_fields = ['manufacturer__name', 'part__name', 'MPN'] @@ -122,24 +56,10 @@ class ManufacturerPartAdmin(ImportExportModelAdmin): autocomplete_fields = ('part', 'manufacturer') -class ManufacturerPartParameterResource(InvenTreeResource): - """Class for managing ManufacturerPartParameter data import/export.""" - - class Meta: - """Metaclass defines extra admin options.""" - - model = ManufacturerPartParameter - skip_unchanged = True - report_skipped = True - clean_model_instance = True - - @admin.register(ManufacturerPartParameter) -class ManufacturerPartParameterAdmin(ImportExportModelAdmin): +class ManufacturerPartParameterAdmin(admin.ModelAdmin): """Admin class for ManufacturerPartParameter model.""" - resource_class = ManufacturerPartParameterResource - list_display = ('manufacturer_part', 'name', 'value') search_fields = ['manufacturer_part__manufacturer__name', 'name', 'value'] @@ -147,61 +67,19 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin): autocomplete_fields = ('manufacturer_part',) -class SupplierPriceBreakResource(InvenTreeResource): - """Class for managing SupplierPriceBreak data import/export.""" - - class Meta: - """Metaclass defines extra admin options.""" - - model = SupplierPriceBreak - skip_unchanged = True - report_skipped = False - clean_model_instances = True - - part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart)) - - supplier_id = Field(attribute='part__supplier__pk', readonly=True) - - supplier_name = Field(attribute='part__supplier__name', readonly=True) - - part_name = Field(attribute='part__part__full_name', readonly=True) - - SKU = Field(attribute='part__SKU', readonly=True) - - MPN = Field(attribute='part__MPN', readonly=True) - - @admin.register(SupplierPriceBreak) -class SupplierPriceBreakAdmin(ImportExportModelAdmin): +class SupplierPriceBreakAdmin(admin.ModelAdmin): """Admin class for the SupplierPriceBreak model.""" - resource_class = SupplierPriceBreakResource - list_display = ('part', 'quantity', 'price') autocomplete_fields = ('part',) -class AddressResource(InvenTreeResource): - """Class for managing Address data import/export.""" - - class Meta: - """Metaclass defining extra options.""" - - model = Address - skip_unchanged = True - report_skipped = False - clean_model_instances = True - - company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company)) - - @admin.register(Address) -class AddressAdmin(ImportExportModelAdmin): +class AddressAdmin(admin.ModelAdmin): """Admin class for the Address model.""" - resource_class = AddressResource - list_display = ('company', 'line1', 'postal_code', 'country') search_fields = ['company', 'country', 'postal_code'] @@ -209,26 +87,10 @@ class AddressAdmin(ImportExportModelAdmin): autocomplete_fields = ['company'] -class ContactResource(InvenTreeResource): - """Class for managing Contact data import/export.""" - - class Meta: - """Metaclass defining extra options.""" - - model = Contact - skip_unchanged = True - report_skipped = False - clean_model_instances = True - - company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company)) - - @admin.register(Contact) -class ContactAdmin(ImportExportModelAdmin): +class ContactAdmin(admin.ModelAdmin): """Admin class for the Contact model.""" - resource_class = ContactResource - list_display = ('company', 'name', 'role', 'email', 'phone') search_fields = ['company', 'name', 'email'] diff --git a/src/backend/InvenTree/importer/admin.py b/src/backend/InvenTree/importer/admin.py index a33f2e7b50..45579ab44c 100644 --- a/src/backend/InvenTree/importer/admin.py +++ b/src/backend/InvenTree/importer/admin.py @@ -69,11 +69,3 @@ class DataImportRowAdmin(admin.ModelAdmin): def get_readonly_fields(self, request, obj=None): """Return the readonly fields for the admin interface.""" return ['session', 'row_index', 'row_data', 'errors', 'valid'] - - -class DataExportAdmin(admin.ModelAdmin): - """Custom admin class mixin allowing for data export functionality.""" - - serializer_class = None - - # TODO: Add custom admin action to export queryset data diff --git a/src/backend/InvenTree/order/admin.py b/src/backend/InvenTree/order/admin.py index a26d7499a3..097492b06b 100644 --- a/src/backend/InvenTree/order/admin.py +++ b/src/backend/InvenTree/order/admin.py @@ -1,54 +1,10 @@ """Admin functionality for the 'order' app.""" from django.contrib import admin -from django.utils.translation import gettext_lazy as _ -from import_export import widgets -from import_export.admin import ImportExportModelAdmin -from import_export.fields import Field - -import stock.models -from InvenTree.admin import InvenTreeResource from order import models -class ProjectCodeResourceMixin: - """Mixin for exporting project code data.""" - - project_code = Field(attribute='project_code', column_name=_('Project Code')) - - def dehydrate_project_code(self, order): - """Return the project code value, not the pk.""" - if order.project_code: - return order.project_code.code - return '' - - -class TotalPriceResourceMixin: - """Mixin for exporting total price data.""" - - total_price = Field(attribute='total_price', column_name=_('Total Price')) - - def dehydrate_total_price(self, order): - """Return the total price amount, not the object itself.""" - if order.total_price: - return order.total_price.amount - return '' - - -class PriceResourceMixin: - """Mixin for 'price' field.""" - - price = Field(attribute='price', column_name=_('Price')) - - def dehydrate_price(self, line): - """Return the price amount, not the object itself.""" - if line.price: - return line.price.amount - return '' - - -# region general classes class GeneralExtraLineAdmin: """Admin class template for the 'ExtraLineItem' models.""" @@ -67,9 +23,6 @@ class GeneralExtraLineMeta: clean_model_instances = True -# endregion - - class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): """Inline admin class for the PurchaseOrderLineItem model.""" @@ -77,35 +30,10 @@ class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): extra = 0 -class PurchaseOrderResource( - ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource -): - """Class for managing import / export of PurchaseOrder data.""" - - class Meta: - """Metaclass options.""" - - model = models.PurchaseOrder - skip_unchanged = True - clean_model_instances = True - exclude = ['metadata'] - - # Add number of line items - line_items = Field( - attribute='line_count', widget=widgets.IntegerWidget(), readonly=True - ) - - # Is this order overdue? - overdue = Field( - attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True - ) - - -class PurchaseOrderAdmin(ImportExportModelAdmin): +@admin.register(models.PurchaseOrder) +class PurchaseOrderAdmin(admin.ModelAdmin): """Admin class for the PurchaseOrder model.""" - resource_class = PurchaseOrderResource - exclude = ['reference_int'] list_display = ('reference', 'supplier', 'status', 'description', 'creation_date') @@ -117,35 +45,10 @@ class PurchaseOrderAdmin(ImportExportModelAdmin): autocomplete_fields = ['supplier', 'project_code', 'contact', 'address'] -class SalesOrderResource( - ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource -): - """Class for managing import / export of SalesOrder data.""" - - class Meta: - """Metaclass options.""" - - model = models.SalesOrder - skip_unchanged = True - clean_model_instances = True - exclude = ['metadata'] - - # Add number of line items - line_items = Field( - attribute='line_count', widget=widgets.IntegerWidget(), readonly=True - ) - - # Is this order overdue? - overdue = Field( - attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True - ) - - -class SalesOrderAdmin(ImportExportModelAdmin): +@admin.register(models.SalesOrder) +class SalesOrderAdmin(admin.ModelAdmin): """Admin class for the SalesOrder model.""" - resource_class = SalesOrderResource - exclude = ['reference_int'] list_display = ('reference', 'customer', 'status', 'description', 'creation_date') @@ -155,89 +58,10 @@ class SalesOrderAdmin(ImportExportModelAdmin): autocomplete_fields = ['customer', 'project_code', 'contact', 'address'] -class PurchaseOrderLineItemResource(PriceResourceMixin, InvenTreeResource): - """Class for managing import / export of PurchaseOrderLineItem data.""" - - class Meta: - """Metaclass.""" - - model = models.PurchaseOrderLineItem - skip_unchanged = True - report_skipped = False - clean_model_instances = True - - part_name = Field(attribute='part__part__name', readonly=True) - - manufacturer = Field(attribute='part__manufacturer', readonly=True) - - MPN = Field(attribute='part__MPN', readonly=True) - - SKU = Field(attribute='part__SKU', readonly=True) - - destination = Field( - attribute='destination', - widget=widgets.ForeignKeyWidget(stock.models.StockLocation), - ) - - def dehydrate_purchase_price(self, line): - """Return a string value of the 'purchase_price' field, rather than the 'Money' object.""" - if line.purchase_price: - return line.purchase_price.amount - return '' - - -class PurchaseOrderExtraLineResource(PriceResourceMixin, InvenTreeResource): - """Class for managing import / export of PurchaseOrderExtraLine data.""" - - class Meta(GeneralExtraLineMeta): - """Metaclass options.""" - - model = models.PurchaseOrderExtraLine - - -class SalesOrderLineItemResource(PriceResourceMixin, InvenTreeResource): - """Class for managing import / export of SalesOrderLineItem data.""" - - class Meta: - """Metaclass options.""" - - model = models.SalesOrderLineItem - skip_unchanged = True - report_skipped = False - clean_model_instances = True - - part_name = Field(attribute='part__name', readonly=True) - - IPN = Field(attribute='part__IPN', readonly=True) - - description = Field(attribute='part__description', readonly=True) - - fulfilled = Field(attribute='fulfilled_quantity', readonly=True) - - def dehydrate_sale_price(self, item): - """Return a string value of the 'sale_price' field, rather than the 'Money' object. - - Ref: https://github.com/inventree/InvenTree/issues/2207 - """ - if item.sale_price: - return item.sale_price.amount - return '' - - -class SalesOrderExtraLineResource(PriceResourceMixin, InvenTreeResource): - """Class for managing import / export of SalesOrderExtraLine data.""" - - class Meta(GeneralExtraLineMeta): - """Metaclass options.""" - - model = models.SalesOrderExtraLine - - -class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): +@admin.register(models.PurchaseOrderLineItem) +class PurchaseOrderLineItemAdmin(admin.ModelAdmin): """Admin class for the PurchaseOrderLine model.""" - resource_class = PurchaseOrderLineItemResource - list_display = ('order', 'part', 'quantity', 'reference') search_fields = ('reference',) @@ -245,17 +69,15 @@ class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): autocomplete_fields = ('order', 'part', 'destination') -class PurchaseOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): +@admin.register(models.PurchaseOrderExtraLine) +class PurchaseOrderExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin): """Admin class for the PurchaseOrderExtraLine model.""" - resource_class = PurchaseOrderExtraLineResource - -class SalesOrderLineItemAdmin(ImportExportModelAdmin): +@admin.register(models.SalesOrderLineItem) +class SalesOrderLineItemAdmin(admin.ModelAdmin): """Admin class for the SalesOrderLine model.""" - resource_class = SalesOrderLineItemResource - list_display = ('order', 'part', 'quantity', 'reference') search_fields = [ @@ -268,13 +90,13 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin): autocomplete_fields = ('order', 'part') -class SalesOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): +@admin.register(models.SalesOrderExtraLine) +class SalesOrderExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin): """Admin class for the SalesOrderExtraLine model.""" - resource_class = SalesOrderExtraLineResource - -class SalesOrderShipmentAdmin(ImportExportModelAdmin): +@admin.register(models.SalesOrderShipment) +class SalesOrderShipmentAdmin(admin.ModelAdmin): """Admin class for the SalesOrderShipment model.""" list_display = ['order', 'shipment_date', 'reference'] @@ -284,7 +106,8 @@ class SalesOrderShipmentAdmin(ImportExportModelAdmin): autocomplete_fields = ('order',) -class SalesOrderAllocationAdmin(ImportExportModelAdmin): +@admin.register(models.SalesOrderAllocation) +class SalesOrderAllocationAdmin(admin.ModelAdmin): """Admin class for the SalesOrderAllocation model.""" list_display = ('line', 'item', 'quantity') @@ -292,25 +115,10 @@ class SalesOrderAllocationAdmin(ImportExportModelAdmin): autocomplete_fields = ('line', 'shipment', 'item') -class ReturnOrderResource( - ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource -): - """Class for managing import / export of ReturnOrder data.""" - - class Meta: - """Metaclass options.""" - - model = models.ReturnOrder - skip_unchanged = True - clean_model_instances = True - exclude = ['metadata'] - - -class ReturnOrderAdmin(ImportExportModelAdmin): +@admin.register(models.ReturnOrder) +class ReturnOrderAdmin(admin.ModelAdmin): """Admin class for the ReturnOrder model.""" - resource_class = ReturnOrderResource - exclude = ['reference_int'] list_display = ['reference', 'customer', 'status'] @@ -320,54 +128,13 @@ class ReturnOrderAdmin(ImportExportModelAdmin): autocomplete_fields = ['customer', 'project_code', 'contact', 'address'] -class ReturnOrderLineItemResource(PriceResourceMixin, InvenTreeResource): - """Class for managing import / export of ReturnOrderLineItem data.""" - - class Meta: - """Metaclass options.""" - - model = models.ReturnOrderLineItem - skip_unchanged = True - report_skipped = False - clean_model_instances = True - - -class ReturnOrderLineItemAdmin(ImportExportModelAdmin): +@admin.register(models.ReturnOrderLineItem) +class ReturnOrderLineItemAdmin(admin.ModelAdmin): """Admin class for ReturnOrderLine model.""" - resource_class = ReturnOrderLineItemResource - list_display = ['order', 'item', 'reference'] -class ReturnOrderExtraLineClass(PriceResourceMixin, InvenTreeResource): - """Class for managing import/export of ReturnOrderExtraLine data.""" - - class Meta(GeneralExtraLineMeta): - """Metaclass options.""" - - model = models.ReturnOrderExtraLine - - -class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): +@admin.register(models.ReturnOrderExtraLine) +class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin): """Admin class for the ReturnOrderExtraLine model.""" - - resource_class = ReturnOrderExtraLineClass - - -# Purchase Order models -admin.site.register(models.PurchaseOrder, PurchaseOrderAdmin) -admin.site.register(models.PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) -admin.site.register(models.PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin) - -# Sales Order models -admin.site.register(models.SalesOrder, SalesOrderAdmin) -admin.site.register(models.SalesOrderLineItem, SalesOrderLineItemAdmin) -admin.site.register(models.SalesOrderExtraLine, SalesOrderExtraLineAdmin) -admin.site.register(models.SalesOrderShipment, SalesOrderShipmentAdmin) -admin.site.register(models.SalesOrderAllocation, SalesOrderAllocationAdmin) - -# Return Order models -admin.site.register(models.ReturnOrder, ReturnOrderAdmin) -admin.site.register(models.ReturnOrderLineItem, ReturnOrderLineItemAdmin) -admin.site.register(models.ReturnOrderExtraLine, ReturnOrdeerExtraLineAdmin) diff --git a/src/backend/InvenTree/part/admin.py b/src/backend/InvenTree/part/admin.py index 89a23cefcc..82a0f9ab13 100644 --- a/src/backend/InvenTree/part/admin.py +++ b/src/backend/InvenTree/part/admin.py @@ -1,225 +1,8 @@ """Admin class definitions for the 'part' app.""" from django.contrib import admin -from django.utils.translation import gettext_lazy as _ -from import_export import widgets -from import_export.admin import ImportExportModelAdmin -from import_export.fields import Field - -from company.models import SupplierPart -from InvenTree.admin import InvenTreeResource from part import models -from stock.models import StockLocation - - -class PartResource(InvenTreeResource): - """Class for managing Part data import/export.""" - - class Meta: - """Metaclass options.""" - - model = models.Part - skip_unchanged = True - report_skipped = False - clean_model_instances = True - exclude = [ - 'bom_checksum', - 'bom_checked_by', - 'bom_checked_date', - 'lft', - 'rght', - 'tree_id', - 'level', - 'metadata', - 'barcode_data', - 'barcode_hash', - ] - - id = Field(attribute='pk', column_name=_('Part ID'), widget=widgets.IntegerWidget()) - name = Field( - attribute='name', column_name=_('Part Name'), widget=widgets.CharWidget() - ) - description = Field( - attribute='description', - column_name=_('Part Description'), - widget=widgets.CharWidget(), - ) - IPN = Field(attribute='IPN', column_name=_('IPN'), widget=widgets.CharWidget()) - revision = Field( - attribute='revision', column_name=_('Revision'), widget=widgets.CharWidget() - ) - keywords = Field( - attribute='keywords', column_name=_('Keywords'), widget=widgets.CharWidget() - ) - link = Field(attribute='link', column_name=_('Link'), widget=widgets.CharWidget()) - units = Field( - attribute='units', column_name=_('Units'), widget=widgets.CharWidget() - ) - notes = Field(attribute='notes', column_name=_('Notes')) - image = Field(attribute='image', column_name=_('Part Image')) - category = Field( - attribute='category', - column_name=_('Category ID'), - widget=widgets.ForeignKeyWidget(models.PartCategory), - ) - category_name = Field( - attribute='category__name', column_name=_('Category Name'), readonly=True - ) - default_location = Field( - attribute='default_location', - column_name=_('Default Location ID'), - widget=widgets.ForeignKeyWidget(StockLocation), - ) - default_supplier = Field( - attribute='default_supplier', - column_name=_('Default Supplier ID'), - widget=widgets.ForeignKeyWidget(SupplierPart), - ) - variant_of = Field( - attribute='variant_of', - column_name=_('Variant Of'), - widget=widgets.ForeignKeyWidget(models.Part), - ) - minimum_stock = Field(attribute='minimum_stock', column_name=_('Minimum Stock')) - - # Part Attributes - active = Field( - attribute='active', column_name=_('Active'), widget=widgets.BooleanWidget() - ) - assembly = Field( - attribute='assembly', column_name=_('Assembly'), widget=widgets.BooleanWidget() - ) - component = Field( - attribute='component', - column_name=_('Component'), - widget=widgets.BooleanWidget(), - ) - purchaseable = Field( - attribute='purchaseable', - column_name=_('Purchaseable'), - widget=widgets.BooleanWidget(), - ) - salable = Field( - attribute='salable', column_name=_('Salable'), widget=widgets.BooleanWidget() - ) - is_template = Field( - attribute='is_template', - column_name=_('Template'), - widget=widgets.BooleanWidget(), - ) - trackable = Field( - attribute='trackable', - column_name=_('Trackable'), - widget=widgets.BooleanWidget(), - ) - virtual = Field( - attribute='virtual', column_name=_('Virtual'), widget=widgets.BooleanWidget() - ) - - # Extra calculated meta-data (readonly) - suppliers = Field( - attribute='supplier_count', column_name=_('Suppliers'), readonly=True - ) - in_stock = Field( - attribute='total_stock', - column_name=_('In Stock'), - readonly=True, - widget=widgets.IntegerWidget(), - ) - on_order = Field( - attribute='on_order', - column_name=_('On Order'), - readonly=True, - widget=widgets.IntegerWidget(), - ) - used_in = Field( - attribute='used_in_count', - column_name=_('Used In'), - readonly=True, - widget=widgets.IntegerWidget(), - ) - allocated = Field( - attribute='allocation_count', - column_name=_('Allocated'), - readonly=True, - widget=widgets.IntegerWidget(), - ) - building = Field( - attribute='quantity_being_built', - column_name=_('Building'), - readonly=True, - widget=widgets.IntegerWidget(), - ) - min_cost = Field( - attribute='pricing__overall_min', column_name=_('Minimum Cost'), readonly=True - ) - max_cost = Field( - attribute='pricing__overall_max', column_name=_('Maximum Cost'), readonly=True - ) - - def dehydrate_min_cost(self, part): - """Render minimum cost value for this Part.""" - min_cost = part.pricing.overall_min if part.pricing else None - - if min_cost is not None: - return float(min_cost.amount) - - def dehydrate_max_cost(self, part): - """Render maximum cost value for this Part.""" - max_cost = part.pricing.overall_max if part.pricing else None - - if max_cost is not None: - return float(max_cost.amount) - - def get_queryset(self): - """Prefetch related data for quicker access.""" - query = super().get_queryset() - query = query.prefetch_related( - 'category', - 'used_in', - 'builds', - 'supplier_parts__purchase_order_line_items', - 'stock_items__allocations', - ) - - return query - - def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): - """Rebuild MPTT tree structure after importing Part data.""" - super().after_import(dataset, result, using_transactions, dry_run, **kwargs) - - # Rebuild the Part tree(s) - models.Part.objects.rebuild() - - -class PartImportResource(InvenTreeResource): - """Class for managing Part data import/export.""" - - class Meta(PartResource.Meta): - """Metaclass options.""" - - skip_unchanged = True - report_skipped = False - clean_model_instances = True - exclude = [ - 'id', - 'category__name', - 'creation_date', - 'creation_user', - 'pricing__overall_min', - 'pricing__overall_max', - 'bom_checksum', - 'bom_checked_by', - 'bom_checked_date', - 'lft', - 'rght', - 'tree_id', - 'level', - 'metadata', - 'barcode_data', - 'barcode_hash', - ] class PartParameterInline(admin.TabularInline): @@ -228,11 +11,10 @@ class PartParameterInline(admin.TabularInline): model = models.PartParameter -class PartAdmin(ImportExportModelAdmin): +@admin.register(models.Part) +class PartAdmin(admin.ModelAdmin): """Admin class for the Part model.""" - resource_class = PartResource - list_display = ('full_name', 'description', 'total_stock', 'category') list_filter = ('active', 'assembly', 'is_template', 'virtual') @@ -257,6 +39,7 @@ class PartAdmin(ImportExportModelAdmin): inlines = [PartParameterInline] +@admin.register(models.PartPricing) class PartPricingAdmin(admin.ModelAdmin): """Admin class for PartPricing model.""" @@ -265,81 +48,24 @@ class PartPricingAdmin(admin.ModelAdmin): autocomplete_fields = ['part'] +@admin.register(models.PartStocktake) class PartStocktakeAdmin(admin.ModelAdmin): """Admin class for PartStocktake model.""" list_display = ['part', 'date', 'quantity', 'user'] +@admin.register(models.PartStocktakeReport) class PartStocktakeReportAdmin(admin.ModelAdmin): """Admin class for PartStocktakeReport model.""" list_display = ['date', 'user'] -class PartCategoryResource(InvenTreeResource): - """Class for managing PartCategory data import/export.""" - - class Meta: - """Metaclass options.""" - - model = models.PartCategory - skip_unchanged = True - report_skipped = False - clean_model_instances = True - - exclude = [ - # Exclude MPTT internal model fields - 'lft', - 'rght', - 'tree_id', - 'level', - 'metadata', - 'icon', - ] - - id = Field( - attribute='pk', column_name=_('Category ID'), widget=widgets.IntegerWidget() - ) - name = Field(attribute='name', column_name=_('Category Name')) - description = Field(attribute='description', column_name=_('Description')) - parent = Field( - attribute='parent', - column_name=_('Parent ID'), - widget=widgets.ForeignKeyWidget(models.PartCategory), - ) - parent_name = Field( - attribute='parent__name', column_name=_('Parent Name'), readonly=True - ) - default_location = Field( - attribute='default_location', - column_name=_('Default Location ID'), - widget=widgets.ForeignKeyWidget(StockLocation), - ) - default_keywords = Field(attribute='default_keywords', column_name=_('Keywords')) - pathstring = Field(attribute='pathstring', column_name=_('Category Path')) - - # Calculated fields - parts = Field( - attribute='item_count', - column_name=_('Parts'), - widget=widgets.IntegerWidget(), - readonly=True, - ) - - def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): - """Rebuild MPTT tree structure after importing PartCategory data.""" - super().after_import(dataset, result, using_transactions, dry_run, **kwargs) - - # Rebuild the PartCategory tree(s) - models.PartCategory.objects.rebuild() - - -class PartCategoryAdmin(ImportExportModelAdmin): +@admin.register(models.PartCategory) +class PartCategoryAdmin(admin.ModelAdmin): """Admin class for the PartCategory model.""" - resource_class = PartCategoryResource - list_display = ('name', 'pathstring', 'description') search_fields = ('name', 'description') @@ -347,12 +73,14 @@ class PartCategoryAdmin(ImportExportModelAdmin): autocomplete_fields = ('parent', 'default_location') +@admin.register(models.PartRelated) class PartRelatedAdmin(admin.ModelAdmin): """Class to manage PartRelated objects.""" autocomplete_fields = ('part_1', 'part_2') +@admin.register(models.PartTestTemplate) class PartTestTemplateAdmin(admin.ModelAdmin): """Admin class for the PartTestTemplate model.""" @@ -362,141 +90,10 @@ class PartTestTemplateAdmin(admin.ModelAdmin): autocomplete_fields = ('part',) -class BomItemResource(InvenTreeResource): - """Class for managing BomItem data import/export.""" - - class Meta: - """Metaclass options.""" - - model = models.BomItem - skip_unchanged = True - report_skipped = False - clean_model_instances = True - - exclude = ['checksum', 'part', 'sub_part', 'validated'] - - level = Field(attribute='level', column_name=_('BOM Level'), readonly=True) - - id = Field( - attribute='pk', column_name=_('BOM Item ID'), widget=widgets.IntegerWidget() - ) - - # ID of the parent part - parent_part_id = Field( - attribute='part', - column_name=_('Parent ID'), - widget=widgets.ForeignKeyWidget(models.Part), - ) - parent_part_ipn = Field( - attribute='part__IPN', column_name=_('Parent IPN'), readonly=True - ) - parent_part_name = Field( - attribute='part__name', column_name=_('Parent Name'), readonly=True - ) - part_id = Field( - attribute='sub_part', - column_name=_('Part ID'), - widget=widgets.ForeignKeyWidget(models.Part), - ) - part_ipn = Field( - attribute='sub_part__IPN', column_name=_('Part IPN'), readonly=True - ) - part_revision = Field( - attribute='sub_part__revision', column_name=_('Part Revision'), readonly=True - ) - part_name = Field( - attribute='sub_part__name', column_name=_('Part Name'), readonly=True - ) - part_description = Field( - attribute='sub_part__description', column_name=_('Description'), readonly=True - ) - quantity = Field(attribute='quantity', column_name=_('Quantity')) - reference = Field(attribute='reference', column_name=_('Reference')) - note = Field(attribute='note', column_name=_('Note')) - min_cost = Field( - attribute='sub_part__pricing__overall_min', - column_name=_('Minimum Price'), - readonly=True, - ) - max_cost = Field( - attribute='sub_part__pricing__overall_max', - column_name=_('Maximum Price'), - readonly=True, - ) - - sub_assembly = Field( - attribute='sub_part__assembly', column_name=_('Assembly'), readonly=True - ) - - def dehydrate_min_cost(self, item): - """Render minimum cost value for the BOM line item.""" - min_price = item.sub_part.pricing.overall_min if item.sub_part.pricing else None - - if min_price is not None: - return float(min_price.amount) * float(item.quantity) - - def dehydrate_max_cost(self, item): - """Render maximum cost value for the BOM line item.""" - max_price = item.sub_part.pricing.overall_max if item.sub_part.pricing else None - - if max_price is not None: - return float(max_price.amount) * float(item.quantity) - - def dehydrate_quantity(self, item): - """Special consideration for the 'quantity' field on data export. We do not want a spreadsheet full of "1.0000" (we'd rather "1"). - - Ref: https://django-import-export.readthedocs.io/en/latest/getting_started.html#advanced-data-manipulation-on-export - """ - return float(item.quantity) - - def before_export(self, queryset, *args, **kwargs): - """Perform before exporting data.""" - self.is_importing = kwargs.get('importing', False) - self.include_pricing = kwargs.pop('include_pricing', False) - - def get_fields(self, **kwargs): - """If we are exporting for the purposes of generating a 'bom-import' template, there are some fields which we are not interested in.""" - fields = super().get_fields(**kwargs) - - is_importing = getattr(self, 'is_importing', False) - include_pricing = getattr(self, 'include_pricing', False) - - to_remove = ['metadata'] - - if is_importing or not include_pricing: - # Remove pricing fields in this instance - to_remove += [ - 'sub_part__pricing__overall_min', - 'sub_part__pricing__overall_max', - ] - - if is_importing: - to_remove += [ - 'level', - 'part', - 'part__IPN', - 'part__name', - 'sub_part__name', - 'sub_part__description', - 'sub_part__assembly', - ] - - idx = 0 - - while idx < len(fields): - if fields[idx].attribute in to_remove: - del fields[idx] - else: - idx += 1 - - return fields - - -class BomItemAdmin(ImportExportModelAdmin): +@admin.register(models.BomItem) +class BomItemAdmin(admin.ModelAdmin): """Admin class for the BomItem model.""" - resource_class = BomItemResource - list_display = ('part', 'sub_part', 'quantity') search_fields = ( @@ -509,72 +106,32 @@ class BomItemAdmin(ImportExportModelAdmin): autocomplete_fields = ('part', 'sub_part') -class ParameterTemplateResource(InvenTreeResource): - """Class for managing ParameterTemplate import/export.""" - - # The following fields will be converted from None to '' - CONVERT_NULL_FIELDS = ['choices', 'units'] - - class Meta: - """Metaclass options.""" - - model = models.PartParameterTemplate - skip_unchanged = True - report_skipped = False - clean_model_instances = True - - exclude = ['metadata'] - - -class ParameterTemplateAdmin(ImportExportModelAdmin): +@admin.register(models.PartParameterTemplate) +class ParameterTemplateAdmin(admin.ModelAdmin): """Admin class for the PartParameterTemplate model.""" - resource_class = ParameterTemplateResource - list_display = ('name', 'units') search_fields = ('name', 'units') -class ParameterResource(InvenTreeResource): - """Class for managing PartParameter data import/export.""" - - class Meta: - """Metaclass options.""" - - model = models.PartParameter - skip_unchanged = True - report_skipped = False - clean_model_instance = True - - part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part)) - - part_name = Field(attribute='part__name', readonly=True) - - template = Field( - attribute='template', - widget=widgets.ForeignKeyWidget(models.PartParameterTemplate), - ) - - template_name = Field(attribute='template__name', readonly=True) - - -class ParameterAdmin(ImportExportModelAdmin): +@admin.register(models.PartParameter) +class ParameterAdmin(admin.ModelAdmin): """Admin class for the PartParameter model.""" - resource_class = ParameterResource - list_display = ('part', 'template', 'data') autocomplete_fields = ('part', 'template') +@admin.register(models.PartCategoryParameterTemplate) class PartCategoryParameterAdmin(admin.ModelAdmin): """Admin class for the PartCategoryParameterTemplate model.""" autocomplete_fields = ('category', 'parameter_template') +@admin.register(models.PartSellPriceBreak) class PartSellPriceBreakAdmin(admin.ModelAdmin): """Admin class for the PartSellPriceBreak model.""" @@ -586,6 +143,7 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin): list_display = ('part', 'quantity', 'price') +@admin.register(models.PartInternalPriceBreak) class PartInternalPriceBreakAdmin(admin.ModelAdmin): """Admin class for the PartInternalPriceBreak model.""" @@ -597,18 +155,3 @@ class PartInternalPriceBreakAdmin(admin.ModelAdmin): list_display = ('part', 'quantity', 'price') autocomplete_fields = ('part',) - - -admin.site.register(models.Part, PartAdmin) -admin.site.register(models.PartCategory, PartCategoryAdmin) -admin.site.register(models.PartRelated, PartRelatedAdmin) -admin.site.register(models.BomItem, BomItemAdmin) -admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin) -admin.site.register(models.PartParameter, ParameterAdmin) -admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterAdmin) -admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin) -admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin) -admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin) -admin.site.register(models.PartPricing, PartPricingAdmin) -admin.site.register(models.PartStocktake, PartStocktakeAdmin) -admin.site.register(models.PartStocktakeReport, PartStocktakeReportAdmin) diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 9412bfd169..f097cf39d8 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -46,7 +46,6 @@ from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups from stock.models import StockLocation from . import serializers as part_serializers -from . import views from .models import ( BomItem, BomItemSubstitute, @@ -2225,12 +2224,6 @@ part_api_urls = [ ), ]), ), - # BOM template - path( - 'bom_template/', - views.BomUploadTemplate.as_view(), - name='api-bom-upload-template', - ), path( '/', include([ @@ -2262,8 +2255,6 @@ part_api_urls = [ ), # Part pricing path('pricing/', PartPricingDetail.as_view(), name='api-part-pricing'), - # BOM download - path('bom-download/', views.BomDownload.as_view(), name='api-bom-download'), # Part detail endpoint path('', PartDetail.as_view(), name='api-part-detail'), ]), diff --git a/src/backend/InvenTree/part/bom.py b/src/backend/InvenTree/part/bom.py index 58359e89df..a479b60a53 100644 --- a/src/backend/InvenTree/part/bom.py +++ b/src/backend/InvenTree/part/bom.py @@ -3,41 +3,13 @@ Primarily BOM upload tools. """ -from collections import OrderedDict from typing import Optional -from django.utils.translation import gettext as _ +from .models import Part -from company.models import ManufacturerPart, SupplierPart -from InvenTree.helpers import DownloadFile, GetExportFormats, normalize, str2bool - -from .admin import BomItemResource -from .models import BomItem, BomItemSubstitute, Part - - -def IsValidBOMFormat(fmt): - """Test if a file format specifier is in the valid list of BOM file formats.""" - return fmt.strip().lower() in GetExportFormats() - - -def MakeBomTemplate(fmt): - """Generate a Bill of Materials upload template file (for user download).""" - fmt = fmt.strip().lower() - - if not IsValidBOMFormat(fmt): - fmt = 'csv' - - # Create an "empty" queryset, essentially. - # This will then export just the row headers! - query = BomItem.objects.filter(pk=None) - - dataset = BomItemResource().export(queryset=query, importing=True) - - data = dataset.export(fmt) - - filename = 'InvenTree_BOM_Template.' + fmt - - return DownloadFile(data, filename) +# TODO: 2024-12-17 - This entire file is to be removed +# TODO: Ref: https://github.com/inventree/InvenTree/pull/8685 +# TODO: To be removed as part of https://github.com/inventree/InvenTree/issues/8686 def ExportBom( @@ -66,6 +38,8 @@ def ExportBom( Returns: StreamingHttpResponse: Response that can be passed to the endpoint """ + # TODO: All this will be pruned!!! + """ parameter_data = str2bool(kwargs.get('parameter_data', False)) stock_data = str2bool(kwargs.get('stock_data', False)) supplier_data = str2bool(kwargs.get('supplier_data', False)) @@ -73,9 +47,6 @@ def ExportBom( pricing_data = str2bool(kwargs.get('pricing_data', False)) substitute_part_data = str2bool(kwargs.get('substitute_part_data', False)) - if not IsValidBOMFormat(fmt): - fmt = 'csv' - bom_items = [] uids = [] @@ -114,7 +85,7 @@ def ExportBom( pass if substitute_part_data: - """If requested, add extra columns for all substitute part numbers associated with each line item.""" + # If requested, add extra columns for all substitute part numbers associated with each line item. col_index = 0 substitute_cols = {} @@ -122,7 +93,7 @@ def ExportBom( for bom_item in bom_items: substitutes = BomItemSubstitute.objects.filter(bom_item=bom_item) for s_idx, substitute in enumerate(substitutes): - """Create substitute part IPN column""" + # Create substitute part IPN column. name = f'{_("Substitute IPN")}{s_idx + 1}' value = substitute.part.IPN try: @@ -130,7 +101,7 @@ def ExportBom( except KeyError: substitute_cols[name] = {col_index: value} - """Create substitute part name column""" + # Create substitute part name column. name = f'{_("Substitute Part")}{s_idx + 1}' value = substitute.part.name try: @@ -138,7 +109,7 @@ def ExportBom( except KeyError: substitute_cols[name] = {col_index: value} - """Create substitute part description column""" + # Create substitute part description column. name = f'{_("Substitute Description")}{s_idx + 1}' value = substitute.part.description try: @@ -152,8 +123,8 @@ def ExportBom( add_columns_to_dataset(substitute_cols, len(bom_items)) if parameter_data: - """If requested, add extra columns for each PartParameter associated with each line item.""" - + # If requested, add extra columns for each PartParameter associated with each line item. +3 parameter_cols = {} for b_idx, bom_item in enumerate(bom_items): @@ -177,7 +148,7 @@ def ExportBom( add_columns_to_dataset(parameter_cols_ordered, len(bom_items)) if stock_data: - """If requested, add extra columns for stock data associated with each line item.""" + # If requested, add extra columns for stock data associated with each line item. stock_headers = [ _('Default Location'), @@ -223,7 +194,7 @@ def ExportBom( add_columns_to_dataset(stock_cols, len(bom_items)) if manufacturer_data or supplier_data: - """If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item.""" + # If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item. # Keep track of the supplier parts we have already exported supplier_parts_used = set() @@ -329,3 +300,4 @@ def ExportBom( filename = f'{part.full_name}_BOM.{fmt}' return DownloadFile(data, filename) + """ diff --git a/src/backend/InvenTree/part/test_bom_export.py b/src/backend/InvenTree/part/test_bom_export.py deleted file mode 100644 index 69d9bdee24..0000000000 --- a/src/backend/InvenTree/part/test_bom_export.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Unit testing for BOM export functionality.""" - -import csv - -from django.urls import reverse - -import part.models -from InvenTree.settings import BASE_DIR -from InvenTree.unit_test import InvenTreeTestCase - - -class BomExportTest(InvenTreeTestCase): - """Class for performing unit testing of BOM export functionality.""" - - fixtures = ['category', 'part', 'location', 'bom'] - - roles = 'all' - - def setUp(self): - """Perform test setup functions.""" - super().setUp() - - part.models.Part.objects.rebuild() - - self.url = reverse('api-bom-download', kwargs={'pk': 100}) - - def test_bom_template(self): - """Test that the BOM template can be downloaded from the server.""" - url = reverse('api-bom-upload-template') - - # Download an XLS template - response = self.client.get(url, data={'format': 'xlsx'}) - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.headers['Content-Disposition'], - 'attachment; filename="InvenTree_BOM_Template.xlsx"', - ) - - # Return a simple CSV template - response = self.client.get(url, data={'format': 'csv'}) - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.headers['Content-Disposition'], - 'attachment; filename="InvenTree_BOM_Template.csv"', - ) - - filename = BASE_DIR / '_testfolder' / '_tmp.csv' - - with open(filename, 'wb') as f: - f.write(response.getvalue()) - - with open(filename, encoding='utf-8') as f: - reader = csv.reader(f, delimiter=',') - - for line in reader: - headers = line - break - - expected = [ - 'Part ID', - 'Part IPN', - 'Quantity', - 'Reference', - 'Note', - 'optional', - 'overage', - 'inherited', - 'allow_variants', - ] - - # Ensure all the expected headers are in the provided file - for header in expected: - self.assertIn(header, headers) - - def test_export_csv(self): - """Test BOM download in CSV format.""" - params = { - 'format': 'csv', - 'cascade': True, - 'parameter_data': True, - 'stock_data': True, - 'supplier_data': True, - 'manufacturer_data': True, - } - - response = self.client.get(self.url, data=params) - - self.assertEqual(response.status_code, 200) - - content = response.headers['Content-Disposition'] - self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.csv"') - - filename = BASE_DIR / '_testfolder' / '_tmp.csv' - - with open(filename, 'wb') as f: - f.write(response.getvalue()) - - # Read the file - with open(filename, encoding='utf-8') as f: - reader = csv.reader(f, delimiter=',') - - for line in reader: - headers = line - break - - expected = [ - 'BOM Level', - 'BOM Item ID', - 'Parent ID', - 'Parent IPN', - 'Parent Name', - 'Part ID', - 'Part IPN', - 'Part Revision', - 'Part Name', - 'Description', - 'Assembly', - 'Quantity', - 'optional', - 'consumable', - 'overage', - 'Reference', - 'Note', - 'inherited', - 'allow_variants', - 'Default Location', - 'Total Stock', - 'Available Stock', - 'On Order', - ] - - for header in expected: - self.assertIn(header, headers) - - for header in headers: - self.assertIn(header, expected) - - def test_export_xlsx(self): - """Test BOM download in XLSX format.""" - params = { - 'format': 'xlsx', - 'cascade': True, - 'parameter_data': True, - 'stock_data': True, - 'supplier_data': True, - 'manufacturer_data': True, - } - - response = self.client.get(self.url, data=params) - - self.assertEqual(response.status_code, 200) - - content = response.headers['Content-Disposition'] - self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.xlsx"') - - def test_export_json(self): - """Test BOM download in JSON format.""" - params = { - 'format': 'json', - 'cascade': True, - 'parameter_data': True, - 'stock_data': True, - 'supplier_data': True, - 'manufacturer_data': True, - } - - response = self.client.get(self.url, data=params) - - self.assertEqual(response.status_code, 200) - - content = response.headers['Content-Disposition'] - self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.json"') diff --git a/src/backend/InvenTree/part/test_bom_import.py b/src/backend/InvenTree/part/test_bom_import.py deleted file mode 100644 index 5b214aa81c..0000000000 --- a/src/backend/InvenTree/part/test_bom_import.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Unit testing for BOM upload / import functionality.""" - -from django.core.files.uploadedfile import SimpleUploadedFile -from django.urls import reverse - -import tablib - -from InvenTree.unit_test import InvenTreeAPITestCase -from part.models import Part - - -class BomUploadTest(InvenTreeAPITestCase): - """Test BOM file upload API endpoint.""" - - roles = ['part.add', 'part.change'] - - @classmethod - def setUpTestData(cls): - """Create BOM data as part of setup routine.""" - super().setUpTestData() - - Part.objects.rebuild() - - cls.part = Part.objects.create( - name='Assembly', - description='An assembled part', - assembly=True, - component=False, - ) - - parts = [] - - for i in range(10): - parts.append( - Part( - name=f'Component {i}', - IPN=f'CMP_{i}', - description='A subcomponent that can be used in a BOM', - component=True, - assembly=False, - lft=0, - rght=0, - level=0, - tree_id=0, - ) - ) - - Part.objects.bulk_create(parts) - - def post_bom( - self, - filename, - file_data, - clear_existing=None, - expected_code=None, - content_type='text/plain', - ): - """Helper function for submitting a BOM file.""" - bom_file = SimpleUploadedFile(filename, file_data, content_type=content_type) - - if clear_existing is None: - clear_existing = False - - response = self.post( - reverse('api-bom-import-upload'), - data={'data_file': bom_file}, - expected_code=expected_code, - format='multipart', - ) - - return response - - def test_missing_file(self): - """POST without a file.""" - response = self.post( - reverse('api-bom-import-upload'), data={}, expected_code=400 - ) - - self.assertIn('No file was submitted', str(response.data['data_file'])) - - def test_unsupported_file(self): - """POST with an unsupported file type.""" - response = self.post_bom('sample.txt', b'hello world', expected_code=400) - - self.assertIn('Unsupported file format', str(response.data['data_file'])) - - def test_broken_file(self): - """Test upload with broken (corrupted) files.""" - response = self.post_bom('sample.csv', b'', expected_code=400) - - self.assertIn('The submitted file is empty', str(response.data['data_file'])) - - response = self.post_bom( - 'test.xls', - b'hello world', - expected_code=400, - content_type='application/xls', - ) - - self.assertIn( - 'Unsupported format, or corrupt file', str(response.data['data_file']) - ) - - def test_missing_rows(self): - """Test upload of an invalid file (without data rows).""" - dataset = tablib.Dataset() - - dataset.headers = ['apple', 'banana'] - - response = self.post_bom( - 'test.csv', - bytes(dataset.csv, 'utf8'), - content_type='text/csv', - expected_code=400, - ) - - self.assertIn('No data rows found in file', str(response.data)) - - # Try again, with an .xlsx file - response = self.post_bom( - 'bom.xlsx', dataset.xlsx, content_type='application/xlsx', expected_code=400 - ) - - self.assertIn('No data rows found in file', str(response.data)) - - def test_missing_columns(self): - """Upload extracted data, but with missing columns.""" - url = reverse('api-bom-import-extract') - - rows = [['1', 'test'], ['2', 'test']] - - # Post without columns - response = self.post(url, {}, expected_code=400) - - self.assertIn('This field is required', str(response.data['rows'])) - self.assertIn('This field is required', str(response.data['columns'])) - - response = self.post( - url, {'rows': rows, 'columns': ['part', 'reference']}, expected_code=400 - ) - - self.assertIn("Missing required column: 'quantity'", str(response.data)) - - response = self.post( - url, {'rows': rows, 'columns': ['quantity', 'reference']}, expected_code=400 - ) - - self.assertIn('No part column specified', str(response.data)) - - self.post( - url, {'rows': rows, 'columns': ['quantity', 'part']}, expected_code=201 - ) - - def test_invalid_data(self): - """Upload data which contains errors.""" - dataset = tablib.Dataset() - - # Only these headers are strictly necessary - dataset.headers = ['part_id', 'quantity'] - - components = Part.objects.filter(component=True) - - for idx, cmp in enumerate(components): - if idx == 5: - cmp.component = False - cmp.save() - - dataset.append([cmp.pk, idx]) - - url = reverse('api-bom-import-extract') - - response = self.post(url, {'columns': dataset.headers, 'rows': list(dataset)}) - - rows = response.data['rows'] - - # Returned data must be the same as the original dataset - self.assertEqual(len(rows), len(dataset)) - - for idx, row in enumerate(rows): - data = row['data'] - cmp = components[idx] - - # Should have guessed the correct part - data['part'] = cmp.pk - - # Check some specific error messages - self.assertEqual( - rows[0]['data']['errors']['quantity'], 'Quantity must be greater than zero' - ) - self.assertEqual( - rows[5]['data']['errors']['part'], 'Part is not designated as a component' - ) - - def test_part_guess(self): - """Test part 'guessing' when PK values are not supplied.""" - dataset = tablib.Dataset() - - # Should be able to 'guess' the part from the name - dataset.headers = ['part_name', 'quantity'] - - components = Part.objects.filter(component=True) - - for component in components: - dataset.append([component.name, 10]) - - url = reverse('api-bom-import-extract') - - response = self.post( - url, {'columns': dataset.headers, 'rows': list(dataset)}, expected_code=201 - ) - - rows = response.data['rows'] - - self.assertEqual(len(rows), 10) - - for idx in range(10): - self.assertEqual(rows[idx]['data']['part'], components[idx].pk) - - # Should also be able to 'guess' part by the IPN value - dataset = tablib.Dataset() - - dataset.headers = ['part_ipn', 'quantity'] - - for component in components: - dataset.append([component.IPN, 10]) - - response = self.post( - url, {'columns': dataset.headers, 'rows': list(dataset)}, expected_code=201 - ) - - rows = response.data['rows'] - - self.assertEqual(len(rows), 10) - - for idx in range(10): - self.assertEqual(rows[idx]['data']['part'], components[idx].pk) - - def test_levels(self): - """Test that multi-level BOMs are correctly handled during upload.""" - url = reverse('api-bom-import-extract') - - dataset = tablib.Dataset() - - dataset.headers = ['level', 'part', 'quantity'] - - components = Part.objects.filter(component=True) - - for idx, cmp in enumerate(components): - dataset.append([idx % 3, cmp.pk, 2]) - - response = self.post( - url, {'rows': list(dataset), 'columns': dataset.headers}, expected_code=201 - ) - - rows = response.data['rows'] - - # Only parts at index 1, 4, 7 should have been returned - self.assertEqual(len(response.data['rows']), 3) - - # Check the returned PK values - self.assertEqual(rows[0]['data']['part'], components[1].pk) - self.assertEqual(rows[1]['data']['part'], components[4].pk) - self.assertEqual(rows[2]['data']['part'], components[7].pk) diff --git a/src/backend/InvenTree/part/test_views.py b/src/backend/InvenTree/part/test_views.py deleted file mode 100644 index 74cf57a323..0000000000 --- a/src/backend/InvenTree/part/test_views.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Unit tests for Part Views (see views.py).""" - -from django.urls import reverse - -from InvenTree.unit_test import InvenTreeTestCase - - -class PartViewTestCase(InvenTreeTestCase): - """Base class for unit testing the various Part views.""" - - fixtures = ['category', 'part', 'bom', 'location', 'company', 'supplier_part'] - - roles = 'all' - superuser = True - - -class PartDetailTest(PartViewTestCase): - """Unit tests for the PartDetail view.""" - - def test_bom_download(self): - """Test downloading a BOM for a valid part.""" - response = self.client.get( - reverse('api-bom-download', args=(1,)), - headers={'x-requested-with': 'XMLHttpRequest'}, - ) - self.assertEqual(response.status_code, 200) - self.assertIn('streaming_content', dir(response)) diff --git a/src/backend/InvenTree/part/views.py b/src/backend/InvenTree/part/views.py deleted file mode 100644 index 5185631872..0000000000 --- a/src/backend/InvenTree/part/views.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Django views for interacting with Part app.""" - -from django.shortcuts import get_object_or_404 - -from InvenTree.helpers import str2bool -from InvenTree.views import AjaxView - -from .bom import ExportBom, IsValidBOMFormat, MakeBomTemplate -from .models import Part - - -class BomUploadTemplate(AjaxView): - """Provide a BOM upload template file for download. - - - Generates a template file in the provided format e.g. ?format=csv - """ - - def get(self, request, *args, **kwargs): - """Perform a GET request to download the 'BOM upload' template.""" - export_format = request.GET.get('format', 'csv') - - return MakeBomTemplate(export_format) - - -class BomDownload(AjaxView): - """Provide raw download of a BOM file. - - - File format should be passed as a query param e.g. ?format=csv - """ - - role_required = 'part.view' - - model = Part - - def get(self, request, *args, **kwargs): - """Perform GET request to download BOM data.""" - part = get_object_or_404(Part, pk=self.kwargs['pk']) - - export_format = request.GET.get('format', 'csv') - - cascade = str2bool(request.GET.get('cascade', False)) - - parameter_data = str2bool(request.GET.get('parameter_data', False)) - - substitute_part_data = str2bool(request.GET.get('substitute_part_data', False)) - - stock_data = str2bool(request.GET.get('stock_data', False)) - - supplier_data = str2bool(request.GET.get('supplier_data', False)) - - manufacturer_data = str2bool(request.GET.get('manufacturer_data', False)) - - pricing_data = str2bool(request.GET.get('pricing_data', False)) - - levels = request.GET.get('levels', None) - - if levels is not None: - try: - levels = int(levels) - - if levels <= 0: - levels = None - - except ValueError: - levels = None - - if not IsValidBOMFormat(export_format): - export_format = 'csv' - - return ExportBom( - part, - fmt=export_format, - cascade=cascade, - max_levels=levels, - parameter_data=parameter_data, - stock_data=stock_data, - supplier_data=supplier_data, - manufacturer_data=manufacturer_data, - pricing_data=pricing_data, - substitute_part_data=substitute_part_data, - ) - - def get_data(self): - """Return a custom message.""" - return {'info': 'Exported BOM'} diff --git a/src/backend/InvenTree/stock/admin.py b/src/backend/InvenTree/stock/admin.py index cd1bffab79..77f5c49fbd 100644 --- a/src/backend/InvenTree/stock/admin.py +++ b/src/backend/InvenTree/stock/admin.py @@ -2,17 +2,6 @@ from django.contrib import admin from django.db.models import Count -from django.utils.translation import gettext_lazy as _ - -from import_export import widgets -from import_export.admin import ImportExportModelAdmin -from import_export.fields import Field - -from build.models import Build -from company.models import Company, SupplierPart -from InvenTree.admin import InvenTreeResource -from order.models import PurchaseOrder, SalesOrder -from part.models import Part from .models import ( StockItem, @@ -23,61 +12,6 @@ from .models import ( ) -class LocationResource(InvenTreeResource): - """Class for managing StockLocation data import/export.""" - - class Meta: - """Metaclass options.""" - - model = StockLocation - skip_unchanged = True - report_skipped = False - clean_model_instances = True - - exclude = [ - # Exclude MPTT internal model fields - 'lft', - 'rght', - 'tree_id', - 'level', - 'metadata', - 'barcode_data', - 'barcode_hash', - 'owner', - 'icon', - ] - - id = Field( - attribute='id', column_name=_('Location ID'), widget=widgets.IntegerWidget() - ) - name = Field(attribute='name', column_name=_('Location Name')) - description = Field(attribute='description', column_name=_('Description')) - parent = Field( - attribute='parent', - column_name=_('Parent ID'), - widget=widgets.ForeignKeyWidget(StockLocation), - ) - parent_name = Field( - attribute='parent__name', column_name=_('Parent Name'), readonly=True - ) - pathstring = Field(attribute='pathstring', column_name=_('Location Path')) - - # Calculated fields - items = Field( - attribute='item_count', - column_name=_('Stock Items'), - widget=widgets.IntegerWidget(), - readonly=True, - ) - - def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): - """Rebuild after import to keep tree intact.""" - super().after_import(dataset, result, using_transactions, dry_run, **kwargs) - - # Rebuild the StockLocation tree(s) - StockLocation.objects.rebuild() - - class LocationInline(admin.TabularInline): """Inline for sub-locations.""" @@ -85,11 +19,9 @@ class LocationInline(admin.TabularInline): @admin.register(StockLocation) -class LocationAdmin(ImportExportModelAdmin): +class LocationAdmin(admin.ModelAdmin): """Admin class for Location.""" - resource_class = LocationResource - list_display = ('name', 'pathstring', 'description') search_fields = ('name', 'description') @@ -119,168 +51,10 @@ class LocationTypeAdmin(admin.ModelAdmin): return obj.location_count -class StockItemResource(InvenTreeResource): - """Class for managing StockItem data import/export.""" - - class Meta: - """Metaclass options.""" - - model = StockItem - skip_unchanged = True - report_skipped = False - clean_model_instance = True - - exclude = [ - # Exclude MPTT internal model fields - 'lft', - 'rght', - 'tree_id', - 'level', - # Exclude internal fields - 'serial_int', - 'metadata', - 'barcode_hash', - 'barcode_data', - 'owner', - 'status_custom_key', - ] - - id = Field( - attribute='pk', column_name=_('Stock Item ID'), widget=widgets.IntegerWidget() - ) - part = Field( - attribute='part', - column_name=_('Part ID'), - widget=widgets.ForeignKeyWidget(Part), - ) - part_name = Field( - attribute='part__full_name', column_name=_('Part Name'), readonly=True - ) - quantity = Field( - attribute='quantity', column_name=_('Quantity'), widget=widgets.DecimalWidget() - ) - serial = Field(attribute='serial', column_name=_('Serial')) - batch = Field(attribute='batch', column_name=_('Batch')) - status_label = Field( - attribute='status_label', column_name=_('Status'), readonly=True - ) - status = Field( - attribute='status', column_name=_('Status Code'), widget=widgets.IntegerWidget() - ) - location = Field( - attribute='location', - column_name=_('Location ID'), - widget=widgets.ForeignKeyWidget(StockLocation), - ) - location_name = Field( - attribute='location__name', column_name=_('Location Name'), readonly=True - ) - supplier_part = Field( - attribute='supplier_part', - column_name=_('Supplier Part ID'), - widget=widgets.ForeignKeyWidget(SupplierPart), - ) - supplier_part_sku = Field( - attribute='supplier_part__SKU', - column_name=_('Supplier Part SKU'), - readonly=True, - ) - supplier = Field( - attribute='supplier_part__supplier__id', - column_name=_('Supplier ID'), - readonly=True, - widget=widgets.IntegerWidget(), - ) - supplier_name = Field( - attribute='supplier_part__supplier__name', - column_name=_('Supplier Name'), - readonly=True, - ) - customer = Field( - attribute='customer', - column_name=_('Customer ID'), - widget=widgets.ForeignKeyWidget(Company), - ) - belongs_to = Field( - attribute='belongs_to', - column_name=_('Installed In'), - widget=widgets.ForeignKeyWidget(StockItem), - ) - build = Field( - attribute='build', - column_name=_('Build ID'), - widget=widgets.ForeignKeyWidget(Build), - ) - parent = Field( - attribute='parent', - column_name=_('Parent ID'), - widget=widgets.ForeignKeyWidget(StockItem), - ) - sales_order = Field( - attribute='sales_order', - column_name=_('Sales Order ID'), - widget=widgets.ForeignKeyWidget(SalesOrder), - ) - purchase_order = Field( - attribute='purchase_order', - column_name=_('Purchase Order ID'), - widget=widgets.ForeignKeyWidget(PurchaseOrder), - ) - packaging = Field(attribute='packaging', column_name=_('Packaging')) - link = Field(attribute='link', column_name=_('Link')) - notes = Field(attribute='notes', column_name=_('Notes')) - - # Status fields (note that IntegerWidget exports better to excel than BooleanWidget) - is_building = Field( - attribute='is_building', - column_name=_('Building'), - widget=widgets.BooleanWidget(), - ) - review_needed = Field( - attribute='review_needed', - column_name=_('Review Needed'), - widget=widgets.BooleanWidget(), - ) - delete_on_deplete = Field( - attribute='delete_on_deplete', - column_name=_('Delete on Deplete'), - widget=widgets.BooleanWidget(), - ) - - # Date management - updated = Field( - attribute='updated', column_name=_('Last Updated'), widget=widgets.DateWidget() - ) - stocktake_date = Field( - attribute='stocktake_date', - column_name=_('Stocktake'), - widget=widgets.DateWidget(), - ) - expiry_date = Field( - attribute='expiry_date', - column_name=_('Expiry Date'), - widget=widgets.DateWidget(), - ) - - def dehydrate_purchase_price(self, item): - """Render purchase pric as float.""" - if item.purchase_price is not None: - return float(item.purchase_price.amount) - - def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): - """Rebuild after import to keep tree intact.""" - super().after_import(dataset, result, using_transactions, dry_run, **kwargs) - - # Rebuild the StockItem tree(s) - StockItem.objects.rebuild() - - @admin.register(StockItem) -class StockItemAdmin(ImportExportModelAdmin): +class StockItemAdmin(admin.ModelAdmin): """Admin class for StockItem.""" - resource_class = StockItemResource - list_display = ('part', 'quantity', 'location', 'status', 'updated') # A list of search fields which can be used for lookup on matching 'autocomplete' fields @@ -302,7 +76,7 @@ class StockItemAdmin(ImportExportModelAdmin): @admin.register(StockItemTracking) -class StockTrackingAdmin(ImportExportModelAdmin): +class StockTrackingAdmin(admin.ModelAdmin): """Admin class for StockTracking.""" list_display = ('item', 'date', 'label') diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 8062d197e0..edaa46aeab 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -1,13 +1,13 @@ """DRF API definition for the 'users' app.""" import datetime -import logging from django.contrib.auth import get_user, login from django.contrib.auth.models import Group, User from django.urls import include, path, re_path from django.views.generic.base import RedirectView +import structlog from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from rest_framework import exceptions, permissions from rest_framework.generics import DestroyAPIView @@ -39,7 +39,7 @@ from users.serializers import ( RoleSerializer, ) -logger = logging.getLogger('inventree') +logger = structlog.get_logger('inventree') class OwnerList(ListAPI): diff --git a/src/backend/requirements.in b/src/backend/requirements.in index 9c739dabf5..f5b6b8ec2e 100644 --- a/src/backend/requirements.in +++ b/src/backend/requirements.in @@ -10,7 +10,6 @@ django-error-report-2 # Error report viewer for the admin inte django-filter # Extended filtering options django-flags # Feature flags django-ical # iCal export for calendar views -django-import-export<4.0 # Data import / export for admin interface # FIXED 2024-06-26 see https://github.com/inventree/InvenTree/pull/7521 django-maintenance-mode # Shut down application while reloading etc. django-markdownify # Markdown rendering django-mptt # Modified Preorder Tree Traversal diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index e9bf6b53be..512bf7ab7a 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -385,9 +385,7 @@ cssselect2==0.7.0 \ defusedxml==0.7.1 \ --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 - # via - # odfpy - # python3-openid + # via python3-openid deprecated==1.2.15 \ --hash=sha256:353bc4a8ac4bfc96800ddab349d89c25dec1079f65fd53acdcc1e0b975b21320 \ --hash=sha256:683e561a90de76239796e6b6feac66b99030d2dd3fcf61ef996330f14bbb9b0d @@ -396,10 +394,6 @@ deprecated==1.2.15 \ # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-exporter-otlp-proto-http # opentelemetry-semantic-conventions -diff-match-patch==20241021 \ - --hash=sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782 \ - --hash=sha256:beae57a99fa48084532935ee2968b8661db861862ec82c6f21f4acdd6d835073 - # via django-import-export django==4.2.17 \ --hash=sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0 \ --hash=sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc @@ -412,7 +406,6 @@ django==4.2.17 \ # django-filter # django-flags # django-ical - # django-import-export # django-js-asset # django-markdownify # django-money @@ -463,10 +456,6 @@ django-ical==1.9.2 \ --hash=sha256:44c9b6fa90d09f25e9ebaa91ed9eb007f079afbc23d6aac909cfc18188a8e90c \ --hash=sha256:74a16bca05735f91a00120cad7250f3c3aa292a9f698a6cfdc544a922c11de70 # via -r src/backend/requirements.in -django-import-export==3.2.0 \ - --hash=sha256:1d3f2cb2ee3cca0386ed60651fa1623be989f130d9fbdf98a67f7dc3a94b8a37 \ - --hash=sha256:38fd7b9439b9e3aa1a4747421c1087a5bc194e915a28d795fb8429a5f8028f2d - # via -r src/backend/requirements.in django-ipware==7.0.1 \ --hash=sha256:d9ec43d2bf7cdf216fed8d494a084deb5761a54860a53b2e74346a4f384cff47 \ --hash=sha256:db16bbee920f661ae7f678e4270460c85850f03c6761a4eaeb489bdc91f64709 @@ -1000,9 +989,6 @@ oauthlib==3.2.2 \ --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \ --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918 # via requests-oauthlib -odfpy==1.4.1 \ - --hash=sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec - # via tablib openpyxl==3.1.5 \ --hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \ --hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050 @@ -1593,12 +1579,10 @@ structlog==24.4.0 \ --hash=sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610 \ --hash=sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4 # via django-structlog -tablib[html, ods, xls, xlsx, yaml]==3.7.0 \ - --hash=sha256:9a6930037cfe0f782377963ca3f2b1dae3fd4cdbf0883848f22f1447e7bb718b \ - --hash=sha256:f9db84ed398df5109bd69c11d46613d16cc572fb9ad3213f10d95e2b5f12c18e - # via - # -r src/backend/requirements.in - # django-import-export +tablib[xls, xlsx, yaml]==3.5.0 \ + --hash=sha256:9821caa9eca6062ff7299fa645e737aecff982e6b2b42046928a6413c8dabfd9 \ + --hash=sha256:f6661dfc45e1d4f51fa8a6239f9c8349380859a5bfaa73280645f046d6c96e33 + # via -r src/backend/requirements.in tinycss2==1.4.0 \ --hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \ --hash=sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289 diff --git a/src/frontend/src/components/buttons/SSOButton.tsx b/src/frontend/src/components/buttons/SSOButton.tsx index 4454c200e8..fc136768f7 100644 --- a/src/frontend/src/components/buttons/SSOButton.tsx +++ b/src/frontend/src/components/buttons/SSOButton.tsx @@ -15,6 +15,7 @@ import { } from '@tabler/icons-react'; import { t } from '@lingui/macro'; +import { showNotification } from '@mantine/notifications'; import { api } from '../../App'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { apiUrl } from '../../states/ApiState'; @@ -46,6 +47,13 @@ export function SsoButton({ provider }: Readonly<{ provider: Provider }>) { .then(() => { // redirect to login window.location.href = provider.login; + }) + .catch(() => { + showNotification({ + title: t`Error`, + message: t`Sign in redirect failed.`, + color: 'red' + }); }); } diff --git a/src/frontend/src/components/dashboard/widgets/NewsWidget.tsx b/src/frontend/src/components/dashboard/widgets/NewsWidget.tsx index 183ad90179..1ac003ff2e 100644 --- a/src/frontend/src/components/dashboard/widgets/NewsWidget.tsx +++ b/src/frontend/src/components/dashboard/widgets/NewsWidget.tsx @@ -99,7 +99,8 @@ export default function NewsWidget() { }) .then(() => { newsItems.refetch(); - }); + }) + .catch(() => {}); }, [newsItems] ); diff --git a/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx b/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx index 0ec30de04a..66e70d8c5c 100644 --- a/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx +++ b/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx @@ -48,7 +48,8 @@ function QueryCountWidget({ limit: 1 } }) - .then((res) => res.data); + .then((res) => res.data) + .catch(() => {}); } }); diff --git a/src/frontend/src/components/forms/AuthenticationForm.tsx b/src/frontend/src/components/forms/AuthenticationForm.tsx index 5ae0e5f0db..1cd64f898c 100644 --- a/src/frontend/src/components/forms/AuthenticationForm.tsx +++ b/src/frontend/src/components/forms/AuthenticationForm.tsx @@ -15,6 +15,7 @@ import { useDisclosure } from '@mantine/hooks'; import { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { showNotification } from '@mantine/notifications'; import { api } from '../../App'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { @@ -44,26 +45,31 @@ export function AuthenticationForm() { setIsLoggingIn(true); if (classicLoginMode === true) { - doBasicLogin( - classicForm.values.username, - classicForm.values.password - ).then(() => { - setIsLoggingIn(false); + doBasicLogin(classicForm.values.username, classicForm.values.password) + .then(() => { + setIsLoggingIn(false); - if (isLoggedIn()) { - showLoginNotification({ - title: t`Login successful`, - message: t`Logged in successfully` - }); - followRedirect(navigate, location?.state); - } else { - showLoginNotification({ + if (isLoggedIn()) { + showLoginNotification({ + title: t`Login successful`, + message: t`Logged in successfully` + }); + followRedirect(navigate, location?.state); + } else { + showLoginNotification({ + title: t`Login failed`, + message: t`Check your input and try again.`, + success: false + }); + } + }) + .catch(() => { + showNotification({ title: t`Login failed`, message: t`Check your input and try again.`, - success: false + color: 'red' }); - } - }); + }); } else { doSimpleLogin(simpleForm.values.email).then((ret) => { setIsLoggingIn(false); @@ -192,7 +198,7 @@ export function RegistrationForm() { headers: { Authorization: '' } }) .then((ret) => { - if (ret?.status === 204) { + if (ret?.status === 204 || ret?.status === 201) { setIsRegistering(false); showLoginNotification({ title: t`Registration successful`, diff --git a/src/frontend/src/components/plugins/RemoteComponent.tsx b/src/frontend/src/components/plugins/RemoteComponent.tsx index d5834bba7b..16dadedf4d 100644 --- a/src/frontend/src/components/plugins/RemoteComponent.tsx +++ b/src/frontend/src/components/plugins/RemoteComponent.tsx @@ -53,18 +53,24 @@ export default function RemoteComponent({ } if (sourceFile && functionName) { - findExternalPluginFunction(sourceFile, functionName).then((func) => { - if (func) { - try { - func(componentRef.current, context); - setRenderingError(''); - } catch (error) { - setRenderingError(`${error}`); + findExternalPluginFunction(sourceFile, functionName) + .then((func) => { + if (func) { + try { + func(componentRef.current, context); + setRenderingError(''); + } catch (error) { + setRenderingError(`${error}`); + } + } else { + setRenderingError(`${sourceFile}:${functionName}`); } - } else { - setRenderingError(`${sourceFile}:${functionName}`); - } - }); + }) + .catch((_error) => { + console.error( + `ERR: Failed to load remove plugin function: ${sourceFile}:${functionName}` + ); + }); } else { setRenderingError( `${t`Invalid source or function name`} - ${sourceFile}:${functionName}` diff --git a/src/frontend/src/states/IconState.tsx b/src/frontend/src/states/IconState.tsx index 008d93ffb4..d2b9de3a95 100644 --- a/src/frontend/src/states/IconState.tsx +++ b/src/frontend/src/states/IconState.tsx @@ -1,5 +1,7 @@ import { create } from 'zustand'; +import { t } from '@lingui/macro'; +import { showNotification } from '@mantine/notifications'; import { api } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { generateUrl } from '../functions/urls'; @@ -38,17 +40,29 @@ export const useIconState = create()((set, get) => ({ await Promise.all( packs.data.map(async (pack: any) => { - const fontName = `inventree-icon-font-${pack.prefix}`; - const src = Object.entries(pack.fonts as Record) - .map( - ([format, url]) => `url(${generateUrl(url)}) format("${format}")` - ) - .join(',\n'); - const font = new FontFace(fontName, `${src};`); - await font.load(); - document.fonts.add(font); + if (pack.prefix && pack.fonts) { + const fontName = `inventree-icon-font-${pack.prefix}`; + const src = Object.entries(pack.fonts as Record) + .map( + ([format, url]) => `url(${generateUrl(url)}) format("${format}")` + ) + .join(',\n'); + const font = new FontFace(fontName, `${src};`); + await font.load(); + document.fonts.add(font); + return font; + } else { + console.error( + "ERR: Icon package is missing 'prefix' or 'fonts' field" + ); + showNotification({ + title: t`Error`, + message: t`Error loading icon package from server`, + color: 'red' + }); - return font; + return null; + } }) ); @@ -56,7 +70,7 @@ export const useIconState = create()((set, get) => ({ hasLoaded: true, packages: packs.data, packagesMap: Object.fromEntries( - packs.data.map((pack: any) => [pack.prefix, pack]) + packs.data?.map((pack: any) => [pack.prefix, pack]) ) }); } diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index bceb9a9d2f..e9469aaaa7 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -12,6 +12,7 @@ import type React from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { showNotification } from '@mantine/notifications'; import { api } from '../App'; import { Boundary } from '../components/Boundary'; import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; @@ -198,6 +199,15 @@ export function InvenTreeTable>({ setTableColumnNames(cacheKey)(names); } + return null; + }) + .catch(() => { + showNotification({ + title: t`API Error`, + message: t`Failed to load table options`, + color: 'red' + }); + return null; }); }