mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 04:25:42 +00:00
Remove django-import-export (#8685)
* Remove django-import-export requirement * Update settings.py * Clean up admin files * Remove much of the old BOM exporter framework * Add note for future self * Remove dead unit test files * Remove defunct AjaxView classes * Remove InvenTreeRoleMixin * Update docs for data import * Export docs
This commit is contained in:
@ -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):
|
||||
|
@ -282,7 +282,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
|
||||
@ -1052,9 +1051,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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -2,57 +2,11 @@
|
||||
|
||||
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')
|
||||
|
||||
|
||||
@admin.register(Build)
|
||||
class BuildAdmin(ImportExportModelAdmin):
|
||||
class BuildAdmin(admin.ModelAdmin):
|
||||
"""Class for managing the Build model via the admin interface"""
|
||||
|
||||
exclude = [
|
||||
|
@ -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)
|
||||
|
@ -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']
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
'<int:pk>/',
|
||||
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'),
|
||||
]),
|
||||
|
@ -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)
|
||||
"""
|
||||
|
@ -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"')
|
@ -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)
|
@ -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))
|
@ -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'}
|
@ -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')
|
||||
|
@ -11,7 +11,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
|
||||
|
@ -383,9 +383,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
|
||||
@ -394,10 +392,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
|
||||
dj-rest-auth==7.0.0 \
|
||||
--hash=sha256:08dbc03a35223872da9f59bc2d7a71bec2e721aa69f7cdc84c7a329aeae1f86e
|
||||
# via -r src/backend/requirements.in
|
||||
@ -415,7 +409,6 @@ django==4.2.17 \
|
||||
# django-filter
|
||||
# django-flags
|
||||
# django-ical
|
||||
# django-import-export
|
||||
# django-js-asset
|
||||
# django-markdownify
|
||||
# django-money
|
||||
@ -473,10 +466,6 @@ django-ical==1.9.2 \
|
||||
--hash=sha256:44c9b6fa90d09f25e9ebaa91ed9eb007f079afbc23d6aac909cfc18188a8e90c \
|
||||
--hash=sha256:74a16bca05735f91a00120cad7250f3c3aa292a9f698a6cfdc544a922c11de70
|
||||
# via -r src/backend/requirements.in
|
||||
django-import-export==3.3.9 \
|
||||
--hash=sha256:16797965e93a8001fe812c61e3b71fb858c57c1bd16da195fe276d6de685348e \
|
||||
--hash=sha256:dd6cabc08ed6d1bd37a392e7fb542bd7d196b615c800168f5c69f0f55f49b103
|
||||
# via -r src/backend/requirements.in
|
||||
django-ipware==7.0.1 \
|
||||
--hash=sha256:d9ec43d2bf7cdf216fed8d494a084deb5761a54860a53b2e74346a4f384cff47 \
|
||||
--hash=sha256:db16bbee920f661ae7f678e4270460c85850f03c6761a4eaeb489bdc91f64709
|
||||
@ -944,9 +933,6 @@ markdown==3.7 \
|
||||
--hash=sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2 \
|
||||
--hash=sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803
|
||||
# via django-markdownify
|
||||
markuppy==1.14 \
|
||||
--hash=sha256:1adee2c0a542af378fe84548ff6f6b0168f3cb7f426b46961038a2bcfaad0d5f
|
||||
# via tablib
|
||||
markupsafe==3.0.2 \
|
||||
--hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \
|
||||
--hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \
|
||||
@ -1010,9 +996,6 @@ markupsafe==3.0.2 \
|
||||
--hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \
|
||||
--hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50
|
||||
# via jinja2
|
||||
odfpy==1.4.1 \
|
||||
--hash=sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec
|
||||
# via tablib
|
||||
openpyxl==3.1.5 \
|
||||
--hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \
|
||||
--hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050
|
||||
@ -1582,12 +1565,10 @@ structlog==24.4.0 \
|
||||
--hash=sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610 \
|
||||
--hash=sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4
|
||||
# via django-structlog
|
||||
tablib[html, ods, xls, xlsx, yaml]==3.5.0 \
|
||||
tablib[xls, xlsx, yaml]==3.5.0 \
|
||||
--hash=sha256:9821caa9eca6062ff7299fa645e737aecff982e6b2b42046928a6413c8dabfd9 \
|
||||
--hash=sha256:f6661dfc45e1d4f51fa8a6239f9c8349380859a5bfaa73280645f046d6c96e33
|
||||
# via
|
||||
# -r src/backend/requirements.in
|
||||
# django-import-export
|
||||
# via -r src/backend/requirements.in
|
||||
tinycss2==1.4.0 \
|
||||
--hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \
|
||||
--hash=sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289
|
||||
|
Reference in New Issue
Block a user