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
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 108 KiB |
Before Width: | Height: | Size: 27 KiB |
Before Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 84 KiB |
BIN
docs/docs/assets/images/admin/import_session_create.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
docs/docs/assets/images/admin/import_session_map.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
docs/docs/assets/images/admin/import_session_process.png
Normal file
After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 28 KiB |
@ -4,14 +4,10 @@ title: Exporting Data
|
||||
|
||||
## Exporting Data
|
||||
|
||||
The Admin Interface provides powerful data exporting capability. When displaying a list of items which support exporting (e.g. Part objects), select the "Export" button from the top-right corner:
|
||||
InvenTree provides data export functionality for a variety of data types. Most data tables provide an "Export Data" button, which allows the user to export the data in a variety of formats.
|
||||
|
||||
{% with id="export", url="admin/export.png", description="Data export" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
Multiple data formats are supported for exported data:
|
||||
|
||||
{% with id="formats", url="admin/formats.png", description="Data formats" %}
|
||||
In the top right corner of the table, click the "Export Data" button to export the data in the table.
|
||||
|
||||
{% with id="export", url="admin/export.png", description="Export data" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
@ -15,77 +15,70 @@ External data can be imported via the admin interface, allowing for rapid integr
|
||||
!!! warning "Supported Models"
|
||||
Not all models in the InvenTree database support bulk import actions.
|
||||
|
||||
When viewing a model (which supports bulk data import) in the admin interface, select the "Import" button in the top-right corner:
|
||||
### Required Permissions
|
||||
|
||||
{% with id="import", url="admin/import.png", description="Data import" %}
|
||||
To import data, the user must have the appropriate permissions. The user must be a *staff* user, and have the `change` permission for the model in question.
|
||||
|
||||
## Import Session
|
||||
|
||||
Importing data is a multi-step process, which is managed via an *import session*. An import session is created when the user initiates a data import, and is used to track the progress of the data import process.
|
||||
|
||||
### Import Session List
|
||||
|
||||
The import session is managed by the InvenTree server, and all import session data is stored on the server. As the import process can be time-consuming, the user can navigate away from the import page and return later to check on the progress of the import.
|
||||
|
||||
Import sessions can be managed from the *Admin Center* page, which lists all available import sessions
|
||||
|
||||
### Context Sensitive Importing
|
||||
|
||||
Depending on the type of data being imported, an import session can be created from an appropriate page context in the user interface. In such cases, the import session will be automatically linked to the relevant data type being imported.
|
||||
|
||||
## Import Process
|
||||
|
||||
The following steps outline the process of importing data into InvenTree:
|
||||
|
||||
### Create Import Session
|
||||
|
||||
An import session can be created via the methods outlined above. The first step is to create an import session, and upload the data file to import. Note that depending on the context of the data import, the user may have to select the database model to import data into.
|
||||
|
||||
{% with id="import-create", url="admin/import_session_create.png", description="Create import session" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
The next screen displays a list of column headings which are expected to be present in the uploaded data file.
|
||||
### Map Data Fields
|
||||
|
||||
{% with id="import_upload", url="admin/import_upload.png", description="Data upload" %}
|
||||
Next, the user must map the data fields in the uploaded file to the fields in the database model. This is a critical step, as the data fields must be correctly matched to the database fields.
|
||||
|
||||
{% with id="import-map", url="admin/import_session_map.png", description="Map data fields" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
Select the data file to import, and the data format. Press the "Submit" button to upload the file.
|
||||
The InvenTree server will attempt to automatically associate the data fields in the uploaded file with the database fields. However, the user may need to manually adjust the field mappings to ensure that the data is imported correctly.
|
||||
|
||||
### File Format
|
||||
### Import Data
|
||||
|
||||
The uploaded data file must meet a number of formatting requirements for successful data upload. A simple way of ensuring that the file format is correct is to first [export data](./export.md) for the model in question, and delete all data rows (not the header row) from the exported data file.
|
||||
Once the data fields have been mapped, the data is loaded from the file, and stored (temporarily) in the import session. This step is performed automatically by the InvenTree server once the user has confirmed the field mappings.
|
||||
|
||||
Then, the same file can be used as a template for uploading more data to the server.
|
||||
Note that this process may take some time if the data file is large. The import process is handled by the background worker process, and the user can navigate away from the import page and return later to check on the progress of the import.
|
||||
|
||||
### ID Field
|
||||
### Process Data
|
||||
|
||||
The uploaded data file requires a special field called `id`. This `id` field uniquely identifies each entry in the database table(s) - it is also known as a *primary key*.
|
||||
Once the data has been loaded into the import session, the user can process the data. This step will attempt to validate the data, and check for any errors or issues that may prevent the data from being imported.
|
||||
|
||||
The `id` column **must** be present in an uploaded data file, as it is required to know how to process the incoming data.
|
||||
|
||||
Depending on the value of the `id` field in each row, InvenTree will attempt to either insert a new record into the database, or update an existing one.
|
||||
|
||||
#### Empty ID
|
||||
|
||||
If the `id` field in a given data row is empty (blank), then InvenTree interprets that particular row as a *new* entry which will be inserted into the database.
|
||||
|
||||
If you wish for a new database entry to be created for a particular data row, the `id` field **must** be left blank for that row.
|
||||
|
||||
#### Non-Empty ID
|
||||
|
||||
If the `id` field in a given data row is *not* empty, then InvenTree interprets that particular row as an *existing* row to override / update.
|
||||
|
||||
In this case, InvenTree will search the database for an entry with the matching `id`. If a matching entry is found, then the entry is updated with the provided data.
|
||||
|
||||
However, if an entry is *not* found with the matching `id`, InvenTree will return an error message, as it cannot find the matching database entry to update.
|
||||
|
||||
!!! warning "Check id Value"
|
||||
Exercise caution when uploading data with the `id` field specified!
|
||||
|
||||
### Import Preview
|
||||
|
||||
After the data file has been uploaded and validated, the user is presented with a *preview* screen, showing the records that will be inserted or updated in the database.
|
||||
|
||||
Here the user has a final chance to review the data upload.
|
||||
|
||||
Press the *Confirm Import* button to actually perform the import process and commit the data into the database.
|
||||
|
||||
{% with id="import_preview", url="admin/import_preview.png", description="Data upload preview" %}
|
||||
{% with id="import-process", url="admin/import_session_process.png", description="Process data" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
Note that *new* records are automatically assigned an `id` value.
|
||||
Note that each row must be selected and confirmed by the user before it is actually imported into the database. Any errors which are detected will be displayed to the user, and the user can choose to correct the data and re-process it.
|
||||
|
||||
## Import Errors
|
||||
During the processing step, the status of each row is displayed at the left of the table. Each row can be in one of the following states:
|
||||
|
||||
Manually importing data in a relational database is a complex process. You may be presented with an error message which describes why the data could not be imported.
|
||||
- **Error**: The row contains an error which must be corrected before it can be imported.
|
||||
- **Pending**: The row contains no errors, and is ready to be imported.
|
||||
- **Imported**: The row has been successfully imported into the database.
|
||||
|
||||
The error message should contain enough information to manually edit the data file to fix the problem.
|
||||
Each individual row can be imported, or removed (deleted) by the user. Once all the rows have been processed, the import session is considered *complete*.
|
||||
|
||||
Any error messages are displayed per row, and you can hover the mouse over the particular error message to view specific error details:
|
||||
### Import Completed
|
||||
|
||||
{% with id="import_error", url="admin/import_error.png", description="Data upload error" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
|
||||
!!! info "Report Issue"
|
||||
If the error message does not provide enough information, or the error seems like a bug caused by InvenTree itself, report an [issue on Github](https://github.com/inventree/inventree/issues).
|
||||
Once all records have been processed, the import session is considered complete. The import session can be closed, and the imported records are now stored in the database.
|
||||
|
@ -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
|
||||
|