diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 61815f0587..ac7cf1f4e1 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -837,6 +837,12 @@ input[type="submit"] { pointer-events: none; /* Prevent this div from blocking links underneath */ } +.notes { + border-radius: 5px; + background-color: #fafafa; + padding: 5px; +} + .alert { display: none; border-radius: 5px; @@ -853,6 +859,11 @@ input[type="submit"] { margin-right: 2px; } +.btn-small { + padding: 3px; + padding-left: 5px; +} + .btn-remove { padding: 3px; padding-left: 5px; diff --git a/InvenTree/InvenTree/static/script/inventree/sidenav.js b/InvenTree/InvenTree/static/script/inventree/sidenav.js index ce1214abc4..eca19076f2 100644 --- a/InvenTree/InvenTree/static/script/inventree/sidenav.js +++ b/InvenTree/InvenTree/static/script/inventree/sidenav.js @@ -35,8 +35,8 @@ function loadTree(url, tree, options={}) { showTags: true, }); - if (sessionStorage.getItem(key)) { - var saved_exp = sessionStorage.getItem(key).split(","); + if (localStorage.getItem(key)) { + var saved_exp = localStorage.getItem(key).split(","); // Automatically expand the desired notes for (var q = 0; q < saved_exp.length; q++) { @@ -57,7 +57,7 @@ function loadTree(url, tree, options={}) { } // Save the expanded nodes - sessionStorage.setItem(key, exp); + localStorage.setItem(key, exp); }); } }, @@ -106,17 +106,17 @@ function initNavTree(options) { width: '0px' }, 50); - sessionStorage.setItem(stateLabel, 'closed'); + localStorage.setItem(stateLabel, 'closed'); } else { - sessionStorage.setItem(stateLabel, 'open'); - sessionStorage.setItem(widthLabel, `${width}px`); + localStorage.setItem(stateLabel, 'open'); + localStorage.setItem(widthLabel, `${width}px`); } } }); } - var state = sessionStorage.getItem(stateLabel); - var width = sessionStorage.getItem(widthLabel) || '300px'; + var state = localStorage.getItem(stateLabel); + var width = localStorage.getItem(widthLabel) || '300px'; if (state && state == 'open') { @@ -131,21 +131,21 @@ function initNavTree(options) { $(toggleId).click(function() { - var state = sessionStorage.getItem(stateLabel) || 'closed'; - var width = sessionStorage.getItem(widthLabel) || '300px'; + var state = localStorage.getItem(stateLabel) || 'closed'; + var width = localStorage.getItem(widthLabel) || '300px'; if (state == 'open') { $(treeId).animate({ width: '0px' }, 50); - sessionStorage.setItem(stateLabel, 'closed'); + localStorage.setItem(stateLabel, 'closed'); } else { $(treeId).animate({ width: width, }, 50); - sessionStorage.setItem(stateLabel, 'open'); + localStorage.setItem(stateLabel, 'open'); } }); } @@ -198,17 +198,18 @@ function enableNavbar(options) { width: '45px' }, 50); - sessionStorage.setItem(stateLabel, 'closed'); + localStorage.setItem(stateLabel, 'closed'); } else { - sessionStorage.setItem(widthLabel, `${width}px`); - sessionStorage.setItem(stateLabel, 'open'); + localStorage.setItem(widthLabel, `${width}px`); + localStorage.setItem(stateLabel, 'open'); } } }); } - var state = sessionStorage.getItem(stateLabel); - var width = sessionStorage.getItem(widthLabel) || '250px'; + var state = localStorage.getItem(stateLabel); + + var width = localStorage.getItem(widthLabel) || '250px'; if (state && state == 'open') { @@ -224,8 +225,8 @@ function enableNavbar(options) { $(toggleId).click(function() { - var state = sessionStorage.getItem(stateLabel) || 'closed'; - var width = sessionStorage.getItem(widthLabel) || '250px'; + var state = localStorage.getItem(stateLabel) || 'closed'; + var width = localStorage.getItem(widthLabel) || '250px'; if (state == 'open') { $(navId).animate({ @@ -233,7 +234,7 @@ function enableNavbar(options) { minWidth: '45px', }, 50); - sessionStorage.setItem(stateLabel, 'closed'); + localStorage.setItem(stateLabel, 'closed'); } else { @@ -241,7 +242,7 @@ function enableNavbar(options) { 'width': width }, 50); - sessionStorage.setItem(stateLabel, 'open'); + localStorage.setItem(stateLabel, 'open'); } }); } diff --git a/InvenTree/InvenTree/static/select2/css/select2-bootstrap.css b/InvenTree/InvenTree/static/select2/css/select2-bootstrap.css deleted file mode 100644 index 50c94c4e1f..0000000000 --- a/InvenTree/InvenTree/static/select2/css/select2-bootstrap.css +++ /dev/null @@ -1,4052 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - select2-bootstrap-theme/select2-bootstrap.css at master · select2/select2-bootstrap-theme - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Skip to content -
- - - - - - - - - - - -
- -
- -
- -
- - - -
-
-
- - - - - - - - - - - - -
-
- -
    - - - -
  • - -
    - -
    - - - Watch - - -
    - Notifications -
    -
    - - - - - - - - -
    -
    -
    - -
    -
  • - -
  • -
    -
    - - -
    -
    - - -
    - -
  • - -
  • -
    - - Fork - -
    - -

    Fork select2-bootstrap-theme

    -
    -
    - -
    -

    If this dialog fails to load, you can visit the fork page directly.

    -
    -
    -
    -
    - - -
  • -
- -

- - /select2-bootstrap-theme - -

- -
- - - - -
-
-
- - - - - - - - Permalink - - - - - -
- - -
- - Branch: - master - - - - - - - -
- -
- - Find file - - - Copy path - -
-
- - -
- - Find file - - - Copy path - -
-
- - - - -
-
- - @fk - fk - - 0.1.0-beta.10 - - - - 87f8621 - Mar 30, 2017 - -
- -
-
- - 1 contributor - - -
- -

- Users who have contributed to this file -

-
- - -
-
-
-
- - - - - -
- -
- -
- 722 lines (625 sloc) - - 22.6 KB -
- -
- -
- Raw - Blame - History -
- - -
- - - - -
- -
-
- -
-
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
/*!
* Select2 Bootstrap Theme v0.1.0-beta.10 (https://select2.github.io/select2-bootstrap-theme)
* Copyright 2015-2017 Florian Kissling and contributors (https://github.com/select2/select2-bootstrap-theme/graphs/contributors)
* Licensed under MIT (https://github.com/select2/select2-bootstrap-theme/blob/master/LICENSE)
*/
-
.select2-container--bootstrap {
display: block;
/*------------------------------------* #COMMON STYLES
\*------------------------------------*/
/**
* Search field in the Select2 dropdown.
*/
/**
* No outline for all search fields - in the dropdown
* and inline in multi Select2s.
*/
/**
* Adjust Select2's choices hover and selected styles to match
* Bootstrap 3's default dropdown styles.
*
* @see http://getbootstrap.com/components/#dropdowns
*/
/**
* Clear the selection.
*/
/**
* Address disabled Select2 styles.
*
* @see https://select2.github.io/examples.html#disabled
* @see http://getbootstrap.com/css/#forms-control-disabled
*/
/*------------------------------------* #DROPDOWN
\*------------------------------------*/
/**
* Dropdown border color and box-shadow.
*/
/**
* Limit the dropdown height.
*/
/*------------------------------------* #SINGLE SELECT2
\*------------------------------------*/
/*------------------------------------* #MULTIPLE SELECT2
\*------------------------------------*/
/**
* Address Bootstrap control sizing classes
*
* 1. Reset Bootstrap defaults.
* 2. Adjust the dropdown arrow button icon position.
*
* @see http://getbootstrap.com/css/#forms-control-sizes
*/
/* 1 */
/*------------------------------------* #RTL SUPPORT
\*------------------------------------*/
}
-
.select2-container--bootstrap .select2-selection {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
background-color: #fff;
border: 1px solid #ccc;
border-radius: 4px;
color: #555555;
font-size: 14px;
outline: 0;
}
-
.select2-container--bootstrap .select2-selection.form-control {
border-radius: 4px;
}
-
.select2-container--bootstrap .select2-search--dropdown .select2-search__field {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
background-color: #fff;
border: 1px solid #ccc;
border-radius: 4px;
color: #555555;
font-size: 14px;
}
-
.select2-container--bootstrap .select2-search__field {
outline: 0;
/* Firefox 18- */
/**
* Firefox 19+
*
* @see http://stackoverflow.com/questions/24236240/color-for-styled-placeholder-text-is-muted-in-firefox
*/
}
-
.select2-container--bootstrap .select2-search__field::-webkit-input-placeholder {
color: #999;
}
-
.select2-container--bootstrap .select2-search__field:-moz-placeholder {
color: #999;
}
-
.select2-container--bootstrap .select2-search__field::-moz-placeholder {
color: #999;
opacity: 1;
}
-
.select2-container--bootstrap .select2-search__field:-ms-input-placeholder {
color: #999;
}
-
.select2-container--bootstrap .select2-results__option {
padding: 6px 12px;
/**
* Disabled results.
*
* @see https://select2.github.io/examples.html#disabled-results
*/
/**
* Hover state.
*/
/**
* Selected state.
*/
}
-
.select2-container--bootstrap .select2-results__option[role=group] {
padding: 0;
}
-
.select2-container--bootstrap .select2-results__option[aria-disabled=true] {
color: #777777;
cursor: not-allowed;
}
-
.select2-container--bootstrap .select2-results__option[aria-selected=true] {
background-color: #f5f5f5;
color: #262626;
}
-
.select2-container--bootstrap .select2-results__option--highlighted[aria-selected] {
background-color: #337ab7;
color: #fff;
}
-
.select2-container--bootstrap .select2-results__option .select2-results__option {
padding: 6px 12px;
}
-
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__group {
padding-left: 0;
}
-
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option {
margin-left: -12px;
padding-left: 24px;
}
-
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -24px;
padding-left: 36px;
}
-
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -36px;
padding-left: 48px;
}
-
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -48px;
padding-left: 60px;
}
-
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -60px;
padding-left: 72px;
}
-
.select2-container--bootstrap .select2-results__group {
color: #777777;
display: block;
padding: 6px 12px;
font-size: 12px;
line-height: 1.42857143;
white-space: nowrap;
}
-
.select2-container--bootstrap.select2-container--focus .select2-selection, .select2-container--bootstrap.select2-container--open .select2-selection {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
-webkit-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
-o-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
-webkit-transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
border-color: #66afe9;
}
-
.select2-container--bootstrap.select2-container--open {
/**
* Make the dropdown arrow point up while the dropdown is visible.
*/
/**
* Handle border radii of the container when the dropdown is showing.
*/
}
-
.select2-container--bootstrap.select2-container--open .select2-selection .select2-selection__arrow b {
border-color: transparent transparent #999 transparent;
border-width: 0 4px 4px 4px;
}
-
.select2-container--bootstrap.select2-container--open.select2-container--below .select2-selection {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
border-bottom-color: transparent;
}
-
.select2-container--bootstrap.select2-container--open.select2-container--above .select2-selection {
border-top-right-radius: 0;
border-top-left-radius: 0;
border-top-color: transparent;
}
-
.select2-container--bootstrap .select2-selection__clear {
color: #999;
cursor: pointer;
float: right;
font-weight: bold;
margin-right: 10px;
}
-
.select2-container--bootstrap .select2-selection__clear:hover {
color: #333;
}
-
.select2-container--bootstrap.select2-container--disabled .select2-selection {
border-color: #ccc;
-webkit-box-shadow: none;
box-shadow: none;
}
-
.select2-container--bootstrap.select2-container--disabled .select2-selection,
.select2-container--bootstrap.select2-container--disabled .select2-search__field {
cursor: not-allowed;
}
-
.select2-container--bootstrap.select2-container--disabled .select2-selection,
.select2-container--bootstrap.select2-container--disabled .select2-selection--multiple .select2-selection__choice {
background-color: #eeeeee;
}
-
.select2-container--bootstrap.select2-container--disabled .select2-selection__clear,
.select2-container--bootstrap.select2-container--disabled .select2-selection--multiple .select2-selection__choice__remove {
display: none;
}
-
.select2-container--bootstrap .select2-dropdown {
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
border-color: #66afe9;
overflow-x: hidden;
margin-top: -1px;
}
-
.select2-container--bootstrap .select2-dropdown--above {
-webkit-box-shadow: 0px -6px 12px rgba(0, 0, 0, 0.175);
box-shadow: 0px -6px 12px rgba(0, 0, 0, 0.175);
margin-top: 1px;
}
-
.select2-container--bootstrap .select2-results > .select2-results__options {
max-height: 200px;
overflow-y: auto;
}
-
.select2-container--bootstrap .select2-selection--single {
height: 34px;
line-height: 1.42857143;
padding: 6px 24px 6px 12px;
/**
* Adjust the single Select2's dropdown arrow button appearance.
*/
}
-
.select2-container--bootstrap .select2-selection--single .select2-selection__arrow {
position: absolute;
bottom: 0;
right: 12px;
top: 0;
width: 4px;
}
-
.select2-container--bootstrap .select2-selection--single .select2-selection__arrow b {
border-color: #999 transparent transparent transparent;
border-style: solid;
border-width: 4px 4px 0 4px;
height: 0;
left: 0;
margin-left: -4px;
margin-top: -2px;
position: absolute;
top: 50%;
width: 0;
}
-
.select2-container--bootstrap .select2-selection--single .select2-selection__rendered {
color: #555555;
padding: 0;
}
-
.select2-container--bootstrap .select2-selection--single .select2-selection__placeholder {
color: #999;
}
-
.select2-container--bootstrap .select2-selection--multiple {
min-height: 34px;
padding: 0;
height: auto;
/**
* Make Multi Select2's choices match Bootstrap 3's default button styles.
*/
/**
* Minus 2px borders.
*/
/**
* Clear the selection.
*/
}
-
.select2-container--bootstrap .select2-selection--multiple .select2-selection__rendered {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
display: block;
line-height: 1.42857143;
list-style: none;
margin: 0;
overflow: hidden;
padding: 0;
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
-
.select2-container--bootstrap .select2-selection--multiple .select2-selection__placeholder {
color: #999;
float: left;
margin-top: 5px;
}
-
.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice {
color: #555555;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
cursor: default;
float: left;
margin: 5px 0 0 6px;
padding: 0 6px;
}
-
.select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field {
background: transparent;
padding: 0 12px;
height: 32px;
line-height: 1.42857143;
margin-top: 0;
min-width: 5em;
}
-
.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove {
color: #999;
cursor: pointer;
display: inline-block;
font-weight: bold;
margin-right: 3px;
}
-
.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove:hover {
color: #333;
}
-
.select2-container--bootstrap .select2-selection--multiple .select2-selection__clear {
margin-top: 6px;
}
-
.select2-container--bootstrap .select2-selection--single.input-sm,
.input-group-sm .select2-container--bootstrap .select2-selection--single,
.form-group-sm .select2-container--bootstrap .select2-selection--single {
border-radius: 3px;
font-size: 12px;
height: 30px;
line-height: 1.5;
padding: 5px 22px 5px 10px;
/* 2 */
}
-
.select2-container--bootstrap .select2-selection--single.input-sm .select2-selection__arrow b,
.input-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,
.form-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b {
margin-left: -5px;
}
-
.select2-container--bootstrap .select2-selection--multiple.input-sm,
.input-group-sm .select2-container--bootstrap .select2-selection--multiple,
.form-group-sm .select2-container--bootstrap .select2-selection--multiple {
min-height: 30px;
border-radius: 3px;
}
-
.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-selection__choice,
.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,
.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice {
font-size: 12px;
line-height: 1.5;
margin: 4px 0 0 5px;
padding: 0 5px;
}
-
.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-search--inline .select2-search__field,
.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,
.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field {
padding: 0 10px;
font-size: 12px;
height: 28px;
line-height: 1.5;
}
-
.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-selection__clear,
.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,
.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear {
margin-top: 5px;
}
-
.select2-container--bootstrap .select2-selection--single.input-lg,
.input-group-lg .select2-container--bootstrap .select2-selection--single,
.form-group-lg .select2-container--bootstrap .select2-selection--single {
border-radius: 6px;
font-size: 18px;
height: 46px;
line-height: 1.3333333;
padding: 10px 31px 10px 16px;
/* 1 */
}
-
.select2-container--bootstrap .select2-selection--single.input-lg .select2-selection__arrow,
.input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow,
.form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow {
width: 5px;
}
-
.select2-container--bootstrap .select2-selection--single.input-lg .select2-selection__arrow b,
.input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,
.form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b {
border-width: 5px 5px 0 5px;
margin-left: -5px;
margin-left: -10px;
margin-top: -2.5px;
}
-
.select2-container--bootstrap .select2-selection--multiple.input-lg,
.input-group-lg .select2-container--bootstrap .select2-selection--multiple,
.form-group-lg .select2-container--bootstrap .select2-selection--multiple {
min-height: 46px;
border-radius: 6px;
}
-
.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-selection__choice,
.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,
.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice {
font-size: 18px;
line-height: 1.3333333;
border-radius: 4px;
margin: 9px 0 0 8px;
padding: 0 10px;
}
-
.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-search--inline .select2-search__field,
.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,
.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field {
padding: 0 16px;
font-size: 18px;
height: 44px;
line-height: 1.3333333;
}
-
.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-selection__clear,
.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,
.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear {
margin-top: 10px;
}
-
.select2-container--bootstrap .select2-selection.input-lg.select2-container--open .select2-selection--single {
/**
* Make the dropdown arrow point up while the dropdown is visible.
*/
}
-
.select2-container--bootstrap .select2-selection.input-lg.select2-container--open .select2-selection--single .select2-selection__arrow b {
border-color: transparent transparent #999 transparent;
border-width: 0 5px 5px 5px;
}
-
.input-group-lg .select2-container--bootstrap .select2-selection.select2-container--open .select2-selection--single {
/**
* Make the dropdown arrow point up while the dropdown is visible.
*/
}
-
.input-group-lg .select2-container--bootstrap .select2-selection.select2-container--open .select2-selection--single .select2-selection__arrow b {
border-color: transparent transparent #999 transparent;
border-width: 0 5px 5px 5px;
}
-
.select2-container--bootstrap[dir="rtl"] {
/**
* Single Select2
*
* 1. Makes sure that .select2-selection__placeholder is positioned
* correctly.
*/
/**
* Multiple Select2
*/
}
-
.select2-container--bootstrap[dir="rtl"] .select2-selection--single {
padding-left: 24px;
padding-right: 12px;
}
-
.select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__rendered {
padding-right: 0;
padding-left: 0;
text-align: right;
/* 1 */
}
-
.select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__clear {
float: left;
}
-
.select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__arrow {
left: 12px;
right: auto;
}
-
.select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__arrow b {
margin-left: 0;
}
-
.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__choice,
.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder,
.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-search--inline {
float: right;
}
-
.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
margin-left: 0;
margin-right: 6px;
}
-
.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
margin-left: 2px;
margin-right: auto;
}
-
/*------------------------------------* #ADDITIONAL GOODIES
\*------------------------------------*/
/**
* Address Bootstrap's validation states
*
* If a Select2 widget parent has one of Bootstrap's validation state modifier
* classes, adjust Select2's border colors and focus states accordingly.
* You may apply said classes to the Select2 dropdown (body > .select2-container)
* via JavaScript match Bootstraps' to make its styles match.
*
* @see http://getbootstrap.com/css/#forms-control-validation
*/
.has-warning .select2-dropdown,
.has-warning .select2-selection {
border-color: #8a6d3b;
}
-
.has-warning .select2-container--focus .select2-selection,
.has-warning .select2-container--open .select2-selection {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;
border-color: #66512c;
}
-
.has-warning.select2-drop-active {
border-color: #66512c;
}
-
.has-warning.select2-drop-active.select2-drop.select2-drop-above {
border-top-color: #66512c;
}
-
.has-error .select2-dropdown,
.has-error .select2-selection {
border-color: #a94442;
}
-
.has-error .select2-container--focus .select2-selection,
.has-error .select2-container--open .select2-selection {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;
border-color: #843534;
}
-
.has-error.select2-drop-active {
border-color: #843534;
}
-
.has-error.select2-drop-active.select2-drop.select2-drop-above {
border-top-color: #843534;
}
-
.has-success .select2-dropdown,
.has-success .select2-selection {
border-color: #3c763d;
}
-
.has-success .select2-container--focus .select2-selection,
.has-success .select2-container--open .select2-selection {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;
border-color: #2b542c;
}
-
.has-success.select2-drop-active {
border-color: #2b542c;
}
-
.has-success.select2-drop-active.select2-drop.select2-drop-above {
border-top-color: #2b542c;
}
-
/**
* Select2 widgets in Bootstrap Input Groups
*
* @see http://getbootstrap.com/components/#input-groups
* @see https://github.com/twbs/bootstrap/blob/master/less/input-groups.less
*/
/**
* Reset rounded corners
*/
.input-group > .select2-hidden-accessible:first-child + .select2-container--bootstrap > .selection > .select2-selection,
.input-group > .select2-hidden-accessible:first-child + .select2-container--bootstrap > .selection > .select2-selection.form-control {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
-
.input-group > .select2-hidden-accessible:not(:first-child) + .select2-container--bootstrap:not(:last-child) > .selection > .select2-selection,
.input-group > .select2-hidden-accessible:not(:first-child) + .select2-container--bootstrap:not(:last-child) > .selection > .select2-selection.form-control {
border-radius: 0;
}
-
.input-group > .select2-hidden-accessible:not(:first-child):not(:last-child) + .select2-container--bootstrap:last-child > .selection > .select2-selection,
.input-group > .select2-hidden-accessible:not(:first-child):not(:last-child) + .select2-container--bootstrap:last-child > .selection > .select2-selection.form-control {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
-
.input-group > .select2-container--bootstrap {
display: table;
table-layout: fixed;
position: relative;
z-index: 2;
width: 100%;
margin-bottom: 0;
/**
* Adjust z-index like Bootstrap does to show the focus-box-shadow
* above appended buttons in .input-group and .form-group.
*/
/**
* Adjust alignment of Bootstrap buttons in Bootstrap Input Groups to address
* Multi Select2's height which - depending on how many elements have been selected -
* may grow taller than its initial size.
*
* @see http://getbootstrap.com/components/#input-groups
*/
}
-
.input-group > .select2-container--bootstrap > .selection > .select2-selection.form-control {
float: none;
}
-
.input-group > .select2-container--bootstrap.select2-container--open, .input-group > .select2-container--bootstrap.select2-container--focus {
z-index: 3;
}
-
.input-group > .select2-container--bootstrap,
.input-group > .select2-container--bootstrap .input-group-btn,
.input-group > .select2-container--bootstrap .input-group-btn .btn {
vertical-align: top;
}
-
/**
* Temporary fix for https://github.com/select2/select2-bootstrap-theme/issues/9
*
* Provides `!important` for certain properties of the class applied to the
* original `<select>` element to hide it.
*
* @see https://github.com/select2/select2/pull/3301
* @see https://github.com/fk/select2/commit/31830c7b32cb3d8e1b12d5b434dee40a6e753ada
*/
.form-control.select2-hidden-accessible {
position: absolute !important;
width: 1px !important;
}
-
/**
* Display override for inline forms
*/
@media (min-width: 768px) {
.form-inline .select2-container--bootstrap {
display: inline-block;
}
}
- - - -
- -
- - - -
- - -
- - -
-
- - - -
- -
- -
-
- - -
- - - - - - -
- - - You can’t perform that action at this time. -
- - - - - - - - - - - - - - -
- - - - diff --git a/InvenTree/company/forms.py b/InvenTree/company/forms.py index 15f380ae44..079e871b84 100644 --- a/InvenTree/company/forms.py +++ b/InvenTree/company/forms.py @@ -12,7 +12,6 @@ from django.utils.translation import ugettext_lazy as _ import django.forms from .models import Company -from .models import ManufacturerPart from .models import SupplierPart from .models import SupplierPriceBreak @@ -35,25 +34,6 @@ class CompanyImageDownloadForm(HelperForm): ] -class EditManufacturerPartForm(HelperForm): - """ Form for editing a ManufacturerPart object """ - - field_prefix = { - 'link': 'fa-link', - 'MPN': 'fa-hashtag', - } - - class Meta: - model = ManufacturerPart - fields = [ - 'part', - 'manufacturer', - 'MPN', - 'description', - 'link', - ] - - class EditSupplierPartForm(HelperForm): """ Form for editing a SupplierPart object """ diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 276225624f..6e5ef08d6e 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -206,24 +206,27 @@ class SupplierPartSerializer(InvenTreeModelSerializer): MPN = serializers.StringRelatedField(source='manufacturer_part.MPN') - manufacturer_part = ManufacturerPartSerializer(read_only=True) + manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True) class Meta: model = SupplierPart fields = [ + 'description', + 'link', + 'manufacturer', + 'manufacturer_detail', + 'manufacturer_part', + 'manufacturer_part_detail', + 'MPN', + 'note', 'pk', + 'packaging', 'part', 'part_detail', 'pretty_name', + 'SKU', 'supplier', 'supplier_detail', - 'SKU', - 'manufacturer', - 'MPN', - 'manufacturer_detail', - 'manufacturer_part', - 'description', - 'link', ] def create(self, validated_data): diff --git a/InvenTree/company/templates/company/detail_manufacturer_part.html b/InvenTree/company/templates/company/detail_manufacturer_part.html index 41eb005b2f..0ff261ec67 100644 --- a/InvenTree/company/templates/company/detail_manufacturer_part.html +++ b/InvenTree/company/templates/company/detail_manufacturer_part.html @@ -53,29 +53,27 @@ {{ block.super }} $("#manufacturer-part-create").click(function () { - launchModalForm( - "{% url 'manufacturer-part-create' %}", - { - data: { - manufacturer: {{ company.id }}, + + constructForm('{% url "api-manufacturer-part-list" %}', { + fields: { + part: {}, + manufacturer: { + value: {{ company.pk }}, }, - success: function() { - $("#part-table").bootstrapTable("refresh"); + MPN: { + icon: 'fa-hashtag', }, - secondary: [ - { - field: 'part', - label: '{% trans "New Part" %}', - title: '{% trans "Create new Part" %}', - url: "{% url 'part-create' %}" - }, - { - field: 'manufacturer', - label: '{% trans "New Manufacturer" %}', - title: '{% trans "Create new Manufacturer" %}', - }, - ] - }); + description: {}, + link: { + icon: 'fa-link', + }, + }, + method: 'POST', + title: '{% trans "Add Manufacturer Part" %}', + onSuccess: function() { + $("#part-table").bootstrapTable("refresh"); + } + }); }); loadManufacturerPartTable( diff --git a/InvenTree/company/templates/company/manufacturer_part_base.html b/InvenTree/company/templates/company/manufacturer_part_base.html index addd9265b8..ed1612ea76 100644 --- a/InvenTree/company/templates/company/manufacturer_part_base.html +++ b/InvenTree/company/templates/company/manufacturer_part_base.html @@ -62,7 +62,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Internal Part" %} {% if part.part %} - {{ part.part.full_name }}{% include "clip.html"%} + {{ part.part.full_name }}{% include "clip.html"%} {% endif %} @@ -118,9 +118,13 @@ $('#edit-part').click(function () { fields: { part: {}, manufacturer: {}, - MPN: {}, + MPN: { + icon: 'fa-hashtag', + }, description: {}, - link: {}, + link: { + icon: 'fa-link', + }, }, title: '{% trans "Edit Manufacturer Part" %}', reload: true, diff --git a/InvenTree/company/templates/company/manufacturer_part_detail.html b/InvenTree/company/templates/company/manufacturer_part_detail.html index 430072a834..3bc789e6b9 100644 --- a/InvenTree/company/templates/company/manufacturer_part_detail.html +++ b/InvenTree/company/templates/company/manufacturer_part_detail.html @@ -18,7 +18,7 @@ {% trans "Internal Part" %} {% if part.part %} - {{ part.part.full_name }} + {{ part.part.full_name }} {% endif %} diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index 40176c7634..2da6d29198 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -230,7 +230,7 @@ class ManufacturerTest(InvenTreeAPITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) # Check manufacturer part - manufacturer_part_id = int(response.data['manufacturer_part']['pk']) + manufacturer_part_id = int(response.data['manufacturer_part_detail']['pk']) url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id}) response = self.get(url) self.assertEqual(response.data['MPN'], 'PART_NUMBER') diff --git a/InvenTree/company/test_views.py b/InvenTree/company/test_views.py index 4c53bbb8c4..6fc4281f2b 100644 --- a/InvenTree/company/test_views.py +++ b/InvenTree/company/test_views.py @@ -194,45 +194,6 @@ class ManufacturerPartViewTests(CompanyViewTestBase): Tests for the ManufacturerPart views. """ - def test_manufacturer_part_create(self): - """ - Test the ManufacturerPartCreate view. - """ - - url = reverse('manufacturer-part-create') - - # First check that we can GET the form - response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # How many manufaturer parts are already in the database? - n = ManufacturerPart.objects.all().count() - - data = { - 'part': 1, - 'manufacturer': 6, - } - - # MPN is required! (form should fail) - (response, errors) = self.post(url, data, valid=False) - - self.assertIsNotNone(errors.get('MPN', None)) - - data['MPN'] = 'TEST-ME-123' - - (response, errors) = self.post(url, data, valid=True) - - # Check that the ManufacturerPart was created! - self.assertEqual(n + 1, ManufacturerPart.objects.all().count()) - - # Try to create duplicate ManufacturerPart - (response, errors) = self.post(url, data, valid=False) - - self.assertIsNotNone(errors.get('__all__', None)) - - # Check that the ManufacturerPart count stayed the same - self.assertEqual(n + 1, ManufacturerPart.objects.all().count()) - def test_supplier_part_create(self): """ Test that the SupplierPartCreate view creates Manufacturer Part. diff --git a/InvenTree/company/urls.py b/InvenTree/company/urls.py index 4fe0519ea9..e9e125d9e4 100644 --- a/InvenTree/company/urls.py +++ b/InvenTree/company/urls.py @@ -38,8 +38,7 @@ company_urls = [ ] manufacturer_part_urls = [ - url(r'^new/?', views.ManufacturerPartCreate.as_view(), name='manufacturer-part-create'), - + url(r'^(?P\d+)/', include([ url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'), url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'), diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py index 60c37a392a..03fe03d411 100644 --- a/InvenTree/company/views.py +++ b/InvenTree/company/views.py @@ -29,7 +29,6 @@ from .models import SupplierPart from part.models import Part -from .forms import EditManufacturerPartForm from .forms import EditSupplierPartForm from .forms import CompanyImageDownloadForm @@ -242,74 +241,6 @@ class ManufacturerPartDetail(DetailView): return ctx -class ManufacturerPartCreate(AjaxCreateView): - """ Create view for making new ManufacturerPart """ - - model = ManufacturerPart - form_class = EditManufacturerPartForm - ajax_template_name = 'company/manufacturer_part_create.html' - ajax_form_title = _('Create New Manufacturer Part') - context_object_name = 'part' - - def get_context_data(self): - """ - Supply context data to the form - """ - - ctx = super().get_context_data() - - # Add 'part' object - form = self.get_form() - - part = form['part'].value() - - try: - part = Part.objects.get(pk=part) - except (ValueError, Part.DoesNotExist): - part = None - - ctx['part'] = part - - return ctx - - def get_form(self): - """ Create Form instance to create a new ManufacturerPart object. - Hide some fields if they are not appropriate in context - """ - form = super(AjaxCreateView, self).get_form() - - if form.initial.get('part', None): - # Hide the part field - form.fields['part'].widget = HiddenInput() - - return form - - def get_initial(self): - """ Provide initial data for new ManufacturerPart: - - - If 'manufacturer_id' provided, pre-fill manufacturer field - - If 'part_id' provided, pre-fill part field - """ - initials = super(ManufacturerPartCreate, self).get_initial().copy() - - manufacturer_id = self.get_param('manufacturer') - part_id = self.get_param('part') - - if manufacturer_id: - try: - initials['manufacturer'] = Company.objects.get(pk=manufacturer_id) - except (ValueError, Company.DoesNotExist): - pass - - if part_id: - try: - initials['part'] = Part.objects.get(pk=part_id) - except (ValueError, Part.DoesNotExist): - pass - - return initials - - class SupplierPartDetail(DetailView): """ Detail view for SupplierPart """ model = SupplierPart diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py index 42f49f9dde..81a0a4eb00 100644 --- a/InvenTree/part/bom.py +++ b/InvenTree/part/bom.py @@ -3,14 +3,9 @@ Functionality for Bill of Material (BOM) management. Primarily BOM upload tools. """ -from rapidfuzz import fuzz -import tablib -import os - from collections import OrderedDict -from django.utils.translation import gettext_lazy as _ -from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ from InvenTree.helpers import DownloadFile, GetExportFormats @@ -145,11 +140,16 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa stock_data = [] # Get part default location try: - stock_data.append(bom_item.sub_part.get_default_location().name) + loc = bom_item.sub_part.get_default_location() + + if loc is not None: + stock_data.append(str(loc.name)) + else: + stock_data.append('') except AttributeError: stock_data.append('') # Get part current stock - stock_data.append(bom_item.sub_part.available_stock) + stock_data.append(str(bom_item.sub_part.available_stock)) for s_idx, header in enumerate(stock_headers): try: @@ -323,177 +323,6 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa data = dataset.export(fmt) - filename = '{n}_BOM.{fmt}'.format(n=part.full_name, fmt=fmt) + filename = f"{part.full_name}_BOM.{fmt}" return DownloadFile(data, filename) - - -class BomUploadManager: - """ Class for managing an uploaded BOM file """ - - # Fields which are absolutely necessary for valid upload - REQUIRED_HEADERS = [ - 'Quantity' - ] - - # Fields which are used for part matching (only one of them is needed) - PART_MATCH_HEADERS = [ - 'Part_Name', - 'Part_IPN', - 'Part_ID', - ] - - # Fields which would be helpful but are not required - OPTIONAL_HEADERS = [ - 'Reference', - 'Note', - 'Overage', - ] - - EDITABLE_HEADERS = [ - 'Reference', - 'Note', - 'Overage' - ] - - HEADERS = REQUIRED_HEADERS + PART_MATCH_HEADERS + OPTIONAL_HEADERS - - def __init__(self, bom_file): - """ Initialize the BomUpload class with a user-uploaded file object """ - - self.process(bom_file) - - def process(self, bom_file): - """ Process a BOM file """ - - self.data = None - - ext = os.path.splitext(bom_file.name)[-1].lower() - - if ext in ['.csv', '.tsv', ]: - # These file formats need string decoding - raw_data = bom_file.read().decode('utf-8') - elif ext in ['.xls', '.xlsx']: - raw_data = bom_file.read() - else: - raise ValidationError({'bom_file': _('Unsupported file format: {f}').format(f=ext)}) - - try: - self.data = tablib.Dataset().load(raw_data) - except tablib.UnsupportedFormat: - raise ValidationError({'bom_file': _('Error reading BOM file (invalid data)')}) - except tablib.core.InvalidDimensions: - raise ValidationError({'bom_file': _('Error reading BOM file (incorrect row size)')}) - - def guess_header(self, header, threshold=80): - """ Try to match a header (from the file) to a list of known headers - - Args: - header - Header name to look for - threshold - Match threshold for fuzzy search - """ - - # Try for an exact match - for h in self.HEADERS: - if h == header: - return h - - # Try for a case-insensitive match - for h in self.HEADERS: - if h.lower() == header.lower(): - return h - - # Try for a case-insensitive match with space replacement - for h in self.HEADERS: - if h.lower() == header.lower().replace(' ', '_'): - return h - - # Finally, look for a close match using fuzzy matching - matches = [] - - for h in self.HEADERS: - ratio = fuzz.partial_ratio(header, h) - if ratio > threshold: - matches.append({'header': h, 'match': ratio}) - - if len(matches) > 0: - matches = sorted(matches, key=lambda item: item['match'], reverse=True) - return matches[0]['header'] - - return None - - def columns(self): - """ Return a list of headers for the thingy """ - headers = [] - - for header in self.data.headers: - headers.append({ - 'name': header, - 'guess': self.guess_header(header) - }) - - return headers - - def col_count(self): - if self.data is None: - return 0 - - return len(self.data.headers) - - def row_count(self): - """ Return the number of rows in the file. """ - - if self.data is None: - return 0 - - return len(self.data) - - def rows(self): - """ Return a list of all rows """ - rows = [] - - for i in range(self.row_count()): - - data = [item for item in self.get_row_data(i)] - - # Is the row completely empty? Skip! - empty = True - - for idx, item in enumerate(data): - if len(str(item).strip()) > 0: - empty = False - - try: - # Excel import casts number-looking-items into floats, which is annoying - if item == int(item) and not str(item) == str(int(item)): - data[idx] = int(item) - except ValueError: - pass - - # Skip empty rows - if empty: - continue - - row = { - 'data': data, - 'index': i - } - - rows.append(row) - - return rows - - def get_row_data(self, index): - """ Retrieve row data at a particular index """ - if self.data is None or index >= len(self.data): - return None - - return self.data[index] - - def get_row_dict(self, index): - """ Retrieve a dict object representing the data row at a particular offset """ - - if self.data is None or index >= len(self.data): - return None - - return self.data.dict[index] diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 36a49006b0..f04f512045 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -11,10 +11,11 @@ from django.utils.translation import ugettext_lazy as _ from mptt.fields import TreeNodeChoiceField from InvenTree.forms import HelperForm -from InvenTree.helpers import GetExportFormats +from InvenTree.helpers import GetExportFormats, clean_decimal from InvenTree.fields import RoundingDecimalFormField import common.models +from common.forms import MatchItemForm from .models import Part, PartCategory, PartRelated from .models import BomItem @@ -55,16 +56,6 @@ class PartImageDownloadForm(HelperForm): ] -class PartImageForm(HelperForm): - """ Form for uploading a Part image """ - - class Meta: - model = Part - fields = [ - 'image', - ] - - class BomExportForm(forms.Form): """ Simple form to let user set BOM export options, before exporting a BOM (bill of materials) file. @@ -143,16 +134,28 @@ class BomValidateForm(HelperForm): ] -class BomUploadSelectFile(HelperForm): - """ Form for importing a BOM. Provides a file input box for upload """ +class BomMatchItemForm(MatchItemForm): + """ Override MatchItemForm fields """ - bom_file = forms.FileField(label=_('BOM file'), required=True, help_text=_("Select BOM file to upload")) + def get_special_field(self, col_guess, row, file_manager): + """ Set special fields """ - class Meta: - model = Part - fields = [ - 'bom_file', - ] + # set quantity field + if 'quantity' in col_guess.lower(): + return forms.CharField( + required=False, + widget=forms.NumberInput(attrs={ + 'name': 'quantity' + str(row['index']), + 'class': 'numberinput', + 'type': 'number', + 'min': '0', + 'step': 'any', + 'value': clean_decimal(row.get('quantity', '')), + }) + ) + + # return default + return super().get_special_field(col_guess, row, file_manager) class CreatePartRelatedForm(HelperForm): diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 8fe5744f06..5ef3a855c0 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -30,7 +30,7 @@ from mptt.models import TreeForeignKey, MPTTModel from stdimage.models import StdImageField -from decimal import Decimal +from decimal import Decimal, InvalidOperation from datetime import datetime from rapidfuzz import fuzz import hashlib @@ -2418,6 +2418,15 @@ class BomItem(models.Model): - If the "sub_part" is trackable, then the "part" must be trackable too! """ + super().clean() + + try: + self.quantity = Decimal(self.quantity) + except InvalidOperation: + raise ValidationError({ + 'quantity': _('Must be a valid number') + }) + try: # Check for circular BOM references if self.sub_part: diff --git a/InvenTree/part/templates/part/attachments.html b/InvenTree/part/templates/part/attachments.html deleted file mode 100644 index 7128980472..0000000000 --- a/InvenTree/part/templates/part/attachments.html +++ /dev/null @@ -1,86 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} - -{% block menubar %} -{% include 'part/navbar.html' with tab='attachments' %} -{% endblock %} - -{% block heading %} -{% trans "Part Attachments" %} -{% endblock %} - -{% block details %} - -{% include "attachment_table.html" with attachments=part.part_attachments %} - -{% endblock %} - -{% block js_ready %} -{{ block.super }} - - loadAttachmentTable( - '{% url "api-part-attachment-list" %}', - { - filters: { - part: {{ part.pk }}, - }, - onEdit: function(pk) { - var url = `/api/part/attachment/${pk}/`; - - constructForm(url, { - fields: { - comment: {}, - }, - title: '{% trans "Edit Attachment" %}', - onSuccess: reloadAttachmentTable, - }); - }, - onDelete: function(pk) { - var url = `/api/part/attachment/${pk}/`; - - constructForm(url, { - method: 'DELETE', - confirmMessage: '{% trans "Confirm Delete Operation" %}', - title: '{% trans "Delete Attachment" %}', - onSuccess: reloadAttachmentTable, - }); - } - } - ); - - enableDragAndDrop( - '#attachment-dropzone', - '{% url "api-part-attachment-list" %}', - { - data: { - part: {{ part.id }}, - }, - label: 'attachment', - success: function(data, status, xhr) { - reloadAttachmentTable(); - } - } - ); - - $("#new-attachment").click(function() { - - constructForm( - '{% url "api-part-attachment-list" %}', - { - method: 'POST', - fields: { - attachment: {}, - comment: {}, - part: { - value: {{ part.pk }}, - hidden: true, - } - }, - onSuccess: reloadAttachmentTable, - title: '{% trans "Add Attachment" %}', - } - ) - }); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index ba3e9be2b9..048b98fc01 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -11,6 +11,12 @@ {% endblock %} {% block details %} + +{% if roles.part.change != True and editing_enabled %} +
+ {% trans "You do not have permission to edit the BOM." %} +
+{% else %} {% if part.bom_checked_date %} {% if part.is_bom_valid %}
@@ -72,6 +78,7 @@
+{% endif %} {% endblock %} diff --git a/InvenTree/part/templates/part/bom_upload/match_fields.html b/InvenTree/part/templates/part/bom_upload/match_fields.html new file mode 100644 index 0000000000..d1f325aaee --- /dev/null +++ b/InvenTree/part/templates/part/bom_upload/match_fields.html @@ -0,0 +1,99 @@ +{% extends "part/bom_upload/upload_file.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block form_alert %} +{% if missing_columns and missing_columns|length > 0 %} + +{% endif %} +{% if duplicates and duplicates|length > 0 %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + {% trans "File Fields" %} + + {% for col in form %} + +
+ + {{ col.name }} + +
+ + {% endfor %} + + + + + {% trans "Match Fields" %} + + {% for col in form %} + + {{ col }} + {% for duplicate in duplicates %} + {% if duplicate == col.value %} + + {% endif %} + {% endfor %} + + {% endfor %} + + {% for row in rows %} + {% with forloop.counter as row_index %} + + + + + {{ row_index }} + {% for item in row.data %} + + + {{ item }} + + {% endfor %} + + {% endwith %} + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.fieldselect').select2({ + width: '100%', + matcher: partialMatcher, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/match_parts.html b/InvenTree/part/templates/part/bom_upload/match_parts.html new file mode 100644 index 0000000000..078ae8122f --- /dev/null +++ b/InvenTree/part/templates/part/bom_upload/match_parts.html @@ -0,0 +1,127 @@ +{% extends "part/bom_upload/upload_file.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} +{% load crispy_forms_tags %} + +{% block form_alert %} +{% if form.errors %} +{% endif %} +{% if form_errors %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + + {% trans "Row" %} + {% trans "Select Part" %} + {% trans "Reference" %} + {% trans "Quantity" %} + {% for col in columns %} + {% if col.guess != 'Quantity' %} + + + + {% if col.guess %} + {{ col.guess }} + {% else %} + {{ col.name }} + {% endif %} + + {% endif %} + {% endfor %} + + + + {% comment %} Dummy row for javascript del_row method {% endcomment %} + {% for row in rows %} + + + + + + {% add row.index 1 %} + + + {% for field in form.visible_fields %} + {% if field.name == row.item_select %} + {{ field }} + {% endif %} + {% endfor %} + {% if row.errors.part %} +

{{ row.errors.part }}

+ {% endif %} + + + {% for field in form.visible_fields %} + {% if field.name == row.reference %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% if row.errors.reference %} +

{{ row.errors.reference }}

+ {% endif %} + + + {% for field in form.visible_fields %} + {% if field.name == row.quantity %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% if row.errors.quantity %} +

{{ row.errors.quantity }}

+ {% endif %} + + {% for item in row.data %} + {% if item.column.guess != 'Quantity' %} + + {% if item.column.guess == 'Overage' %} + {% for field in form.visible_fields %} + {% if field.name == row.overage %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% elif item.column.guess == 'Note' %} + {% for field in form.visible_fields %} + {% if field.name == row.note %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% else %} + {{ item.cell }} + {% endif %} + + + {% endif %} + {% endfor %} + + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.bomselect').select2({ + dropdownAutoWidth: true, + matcher: partialMatcher, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/select_fields.html b/InvenTree/part/templates/part/bom_upload/select_fields.html deleted file mode 100644 index e82223da88..0000000000 --- a/InvenTree/part/templates/part/bom_upload/select_fields.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} -{% load inventree_extras %} - -{% block menubar %} -{% include "part/navbar.html" with tab='bom' %} -{% endblock %} - -{% block heading %} -{% trans "Upload Bill of Materials" %} -{% endblock %} - -{% block details %} - -

{% trans "Step 2 - Select Fields" %}

-
- -{% if missing_columns and missing_columns|length > 0 %} - -{% endif %} - -
- - {% csrf_token %} - - - - - - - - - {% for col in bom_columns %} - - {% endfor %} - - - - - - - {% for col in bom_columns %} - - {% endfor %} - - {% for row in bom_rows %} - - - - {% for item in row.data %} - - {% endfor %} - - {% endfor %} - -
{% trans "File Fields" %} -
- - {{ col.name }} - -
-
{% trans "Match Fields" %} - - {% if col.duplicate %} -

{% trans "Duplicate column selection" %}

- {% endif %} -
- - {{ forloop.counter }} - - {{ item.cell }} -
- -
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/select_parts.html b/InvenTree/part/templates/part/bom_upload/select_parts.html deleted file mode 100644 index 41530e3c55..0000000000 --- a/InvenTree/part/templates/part/bom_upload/select_parts.html +++ /dev/null @@ -1,121 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} -{% load inventree_extras %} - -{% block menubar %} -{% include "part/navbar.html" with tab="bom" %} -{% endblock %} - -{% block heading %} -{% trans "Upload Bill of Materials" %} -{% endblock %} - -{% block details %} - -

{% trans "Step 3 - Select Parts" %}

-
- -{% if form_errors %} - -{% endif %} - -
- - - - {% csrf_token %} - {% load crispy_forms_tags %} - - - - - - - - - - - {% for col in bom_columns %} - - {% endfor %} - - - - {% for row in bom_rows %} - - - - - - {% for item in row.data %} - - {% endfor %} - - {% endfor %} - -
{% trans "Row" %}{% trans "Select Part" %} - - - {% if col.guess %} - {{ col.guess }} - {% else %} - {{ col.name }} - {% endif %} -
- - {% add row.index 1 %} - - - {% if row.errors.part %} -

{{ row.errors.part }}

- {% endif %} -
- {% if item.column.guess == 'Part' %} - {{ item.cell }} - {% if row.errors.part %} -

{{ row.errors.part }}

- {% endif %} - {% elif item.column.guess == 'Quantity' %} - - {% if row.errors.quantity %} -

{{ row.errors.quantity }}

- {% endif %} - {% elif item.column.guess == 'Reference' %} - - {% elif item.column.guess == 'Note' %} - - {% elif item.column.guess == 'Overage' %} - - {% else %} - {{ item.cell }} - {% endif %} - -
- -
- -{% endblock %} - -{% block js_ready %} -{{ block.super }} - -$('.bomselect').select2({ - dropdownAutoWidth: true, - matcher: partialMatcher, -}); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/upload_file.html b/InvenTree/part/templates/part/bom_upload/upload_file.html index 148d32f5da..88592c5ffc 100644 --- a/InvenTree/part/templates/part/bom_upload/upload_file.html +++ b/InvenTree/part/templates/part/bom_upload/upload_file.html @@ -8,13 +8,12 @@ {% endblock %} {% block heading %} -{% trans "Upload Bill of Materials" %} +{% trans "Upload BOM File" %} {% endblock %} {% block details %} -

{% trans "Step 1 - Select BOM File" %}

- +{% block form_alert %}
{% trans "Requirements for BOM upload" %}:
    @@ -22,16 +21,31 @@
  • {% trans "Each part must already exist in the database" %}
+{% endblock %} -
- - {% csrf_token %} - {% load crispy_forms_tags %} +

{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} +{% if description %}- {{ description }}{% endif %}

- + +{% csrf_token %} +{% load crispy_forms_tags %} - {% crispy form %} +{% block form_buttons_top %} +{% endblock form_buttons_top %} + +{{ wizard.management_form }} +{% block form_content %} +{% crispy wizard.form %} +{% endblock form_content %} +
+ +{% block form_buttons_bottom %} +{% if wizard.steps.prev %} + +{% endif %} +
+{% endblock form_buttons_bottom %} {% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index e077e5af01..8a78eb85a5 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -1,6 +1,7 @@ {% extends "part/part_base.html" %} {% load static %} {% load i18n %} +{% load markdownify %} {% block menubar %} @@ -135,11 +136,38 @@ {% endif %} {% if part.responsible %} - d + {% trans "Responsible User" %} {{ part.responsible }} {% endif %} + + + + + + + {% trans "Notes" %} + + +
+ +
+ + + + + {% if part.notes %} +
+ {{ part.notes | markdownify }} +
+ {% endif %} + + +
@@ -238,6 +266,42 @@ {% endblock %} +{% block post_content_panel %} + +
+
+
+
+

{% trans "Parameters" %}

+
+
+
+
+ {% if roles.part.add %} + + {% endif %} +
+
+
+
+
+
+
+
+
+

{% trans "Attachments" %}

+
+
+ {% include "attachment_table.html" %} +
+
+
+
+ +{% endblock %} + {% block js_load %} {{ block.super }} {% endblock %} @@ -245,6 +309,18 @@ {% block js_ready %} {{ block.super }} + $('#edit-notes').click(function() { + constructForm('{% url "api-part-detail" part.pk %}', { + fields: { + notes: { + multiline: true, + } + }, + title: '{% trans "Edit Part Notes" %}', + reload: true, + }); + }); + $(".slidey").change(function() { var field = $(this).attr('fieldname'); @@ -263,4 +339,118 @@ ); }); + loadPartParameterTable( + '#parameter-table', + '{% url "api-part-parameter-list" %}', + { + params: { + part: {{ part.pk }}, + } + } + ); + + $('#param-table').inventreeTable({ + }); + + {% if roles.part.add %} + $('#param-create').click(function() { + + constructForm('{% url "api-part-parameter-list" %}', { + method: 'POST', + fields: { + part: { + value: {{ part.pk }}, + hidden: true, + }, + template: {}, + data: {}, + }, + title: '{% trans "Add Parameter" %}', + onSuccess: function() { + $('#parameter-table').bootstrapTable('refresh'); + } + }); + }); + {% endif %} + + $('.param-edit').click(function() { + var button = $(this); + + launchModalForm(button.attr('url'), { + reload: true, + }); + }); + + $('.param-delete').click(function() { + var button = $(this); + + launchModalForm(button.attr('url'), { + reload: true, + }); + }); + + loadAttachmentTable( + '{% url "api-part-attachment-list" %}', + { + filters: { + part: {{ part.pk }}, + }, + onEdit: function(pk) { + var url = `/api/part/attachment/${pk}/`; + + constructForm(url, { + fields: { + comment: {}, + }, + title: '{% trans "Edit Attachment" %}', + onSuccess: reloadAttachmentTable, + }); + }, + onDelete: function(pk) { + var url = `/api/part/attachment/${pk}/`; + + constructForm(url, { + method: 'DELETE', + confirmMessage: '{% trans "Confirm Delete Operation" %}', + title: '{% trans "Delete Attachment" %}', + onSuccess: reloadAttachmentTable, + }); + } + } + ); + + enableDragAndDrop( + '#attachment-dropzone', + '{% url "api-part-attachment-list" %}', + { + data: { + part: {{ part.id }}, + }, + label: 'attachment', + success: function(data, status, xhr) { + reloadAttachmentTable(); + } + } + ); + + $("#new-attachment").click(function() { + + constructForm( + '{% url "api-part-attachment-list" %}', + { + method: 'POST', + fields: { + attachment: {}, + comment: {}, + part: { + value: {{ part.pk }}, + hidden: true, + } + }, + onSuccess: reloadAttachmentTable, + title: '{% trans "Add Attachment" %}', + } + ) + }); + {% endblock %} diff --git a/InvenTree/part/templates/part/manufacturer.html b/InvenTree/part/templates/part/manufacturer.html deleted file mode 100644 index ba708d70e8..0000000000 --- a/InvenTree/part/templates/part/manufacturer.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} -{% load inventree_extras %} - -{% block menubar %} -{% include 'part/navbar.html' with tab='manufacturers' %} -{% endblock %} - -{% block heading %} -{% trans "Part Manufacturers" %} -{% endblock %} - -{% block details %} - -
-
- -
- - -
-
-
- - -
- -{% endblock %} - -{% block js_load %} -{{ block.super }} -{% endblock %} -{% block js_ready %} - {{ block.super }} - - $('#manufacturer-create').click(function () { - launchModalForm( - "{% url 'manufacturer-part-create' %}", - { - reload: true, - data: { - part: {{ part.id }} - }, - secondary: [ - { - field: 'manufacturer', - label: '{% trans "New Manufacturer" %}', - title: '{% trans "Create new manufacturer" %}', - } - ] - }); - }); - - $("#manufacturer-part-delete").click(function() { - - var selections = $("#manufacturer-table").bootstrapTable("getSelections"); - - deleteManufacturerParts(selections, { - onSuccess: function() { - $("#manufacturer-table").bootstrapTable("refresh"); - } - }); - }); - - loadManufacturerPartTable( - "#manufacturer-table", - "{% url 'api-manufacturer-part-list' %}", - { - params: { - part: {{ part.id }}, - part_detail: true, - manufacturer_detail: true, - }, - } - ); - - linkButtonsToSelection($("#manufacturer-table"), ['#manufacturer-part-options']) - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/navbar.html b/InvenTree/part/templates/part/navbar.html index b4c943dcfa..e8617dc677 100644 --- a/InvenTree/part/templates/part/navbar.html +++ b/InvenTree/part/templates/part/navbar.html @@ -19,12 +19,6 @@ -
  • - - - {% trans "Parameters" %} - -
  • {% if part.is_template %}
  • @@ -78,12 +72,6 @@
  • {% if part.purchaseable and roles.purchase_order.view %} -
  • - - - {% trans "Manufacturers" %} - -
  • @@ -109,7 +97,7 @@
  • - {% trans "Tests" %} + {% trans "Test Templates" %}
  • {% endif %} @@ -121,16 +109,4 @@ {% endif %} -
  • - - - {% trans "Attachments" %} - -
  • -
  • - - - {% trans "Notes" %} - -
  • diff --git a/InvenTree/part/templates/part/notes.html b/InvenTree/part/templates/part/notes.html deleted file mode 100644 index 63e0190d48..0000000000 --- a/InvenTree/part/templates/part/notes.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load crispy_forms_tags %} -{% load i18n %} -{% load markdownify %} - -{% block menubar %} -{% include 'part/navbar.html' with tab='notes' %} -{% endblock %} - -{% block heading %} -{% trans "Part Notes" %} -{% if roles.part.change and not editing %} - -{% endif %} -{% endblock %} - -{% block details %} - -{% if editing %} -
    - {% csrf_token %} - - {{ form }} -
    - - - -
    - -{{ form.media }} - -{% else %} - -
    - {% if part.notes %} -
    - {{ part.notes | markdownify }} -
    - {% endif %} -
    - -{% endif %} - -{% endblock %} - -{% block js_ready %} -{{ block.super }} - -{% if editing %} -{% else %} -$("#edit-notes").click(function() { - location.href = "{% url 'part-notes' part.id %}?edit=1"; -}); -{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/params.html b/InvenTree/part/templates/part/params.html deleted file mode 100644 index 365003b052..0000000000 --- a/InvenTree/part/templates/part/params.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} - -{% block menubar %} -{% include "part/navbar.html" with tab='params' %} -{% endblock %} - -{% block heading %} -{% trans "Part Parameters" %} -{% endblock %} - -{% block details %} -
    -
    - {% if roles.part.add %} - - {% endif %} -
    -
    - -
    - -{% endblock %} - -{% block js_ready %} -{{ block.super }} - - loadPartParameterTable( - '#parameter-table', - '{% url "api-part-parameter-list" %}', - { - params: { - part: {{ part.pk }}, - } - } - ); - - $('#param-table').inventreeTable({ - }); - - {% if roles.part.add %} - $('#param-create').click(function() { - - constructForm('{% url "api-part-parameter-list" %}', { - method: 'POST', - fields: { - part: { - value: {{ part.pk }}, - hidden: true, - }, - template: {}, - data: {}, - }, - title: '{% trans "Add Parameter" %}', - onSuccess: function() { - $('#parameter-table').bootstrapTable('refresh'); - } - }); - }); - {% endif %} - - $('.param-edit').click(function() { - var button = $(this); - - launchModalForm(button.attr('url'), { - reload: true, - }); - }); - - $('.param-delete').click(function() { - var button = $(this); - - launchModalForm(button.attr('url'), { - reload: true, - }); - }); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 53ab0aaf14..391f6d1d35 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -49,9 +49,25 @@ {% if roles.stock.change %} - + {% endif %} {% if part.purchaseable %} {% if roles.purchase_order.add %} @@ -272,14 +288,34 @@ printPartLabels([{{ part.pk }}]); }); - $("#part-count").click(function() { - launchModalForm("/stock/adjust/", { - data: { - action: "count", + function adjustPartStock(action) { + inventreeGet( + '{% url "api-stock-list" %}', + { part: {{ part.id }}, + in_stock: true, + allow_variants: true, + part_detail: true, + location_detail: true, }, - reload: true, - }); + { + success: function(items) { + adjustStock(action, items, { + onSuccess: function() { + location.reload(); + } + }); + }, + } + ); + } + + $("#part-move").click(function() { + adjustPartStock('move'); + }); + + $("#part-count").click(function() { + adjustPartStock('count'); }); $("#price-button").click(function() { diff --git a/InvenTree/part/templates/part/supplier.html b/InvenTree/part/templates/part/supplier.html index c0486cc42a..8ae73cd07c 100644 --- a/InvenTree/part/templates/part/supplier.html +++ b/InvenTree/part/templates/part/supplier.html @@ -6,12 +6,13 @@ {% include 'part/navbar.html' with tab='suppliers' %} {% endblock %} + {% block heading %} {% trans "Part Suppliers" %} {% endblock %} {% block details %} -
    +
    - +
    {% endblock %} +{% block post_content_panel %} + +
    +
    +

    + {% trans "Part Manufacturers" %} +

    +
    + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + +{% endblock %} + {% block js_load %} {{ block.super }} {% endblock %} @@ -90,6 +120,52 @@ } ); - linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options']) + linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options']); + + loadManufacturerPartTable( + '#manufacturer-table', + "{% url 'api-manufacturer-part-list' %}", + { + params: { + part: {{ part.id }}, + part_detail: true, + manufacturer_detail: true, + }, + } + ); + + linkButtonsToSelection($("#manufacturer-table"), ['#manufacturer-part-options']); + + $("#manufacturer-part-delete").click(function() { + + var selections = $("#manufacturer-table").bootstrapTable("getSelections"); + + deleteManufacturerParts(selections, { + onSuccess: function() { + $("#manufacturer-table").bootstrapTable("refresh"); + } + }); + }); + + $('#manufacturer-create').click(function () { + + constructForm('{% url "api-manufacturer-part-list" %}', { + fields: { + part: { + value: {{ part.pk }}, + hidden: true, + }, + manufacturer: {}, + MPN: {}, + description: {}, + link: {}, + }, + method: 'POST', + title: '{% trans "Add Manufacturer Part" %}', + onSuccess: function() { + $("#manufacturer-table").bootstrapTable("refresh"); + } + }); + }); {% endblock %} \ No newline at end of file diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py new file mode 100644 index 0000000000..13ec3a179e --- /dev/null +++ b/InvenTree/part/test_bom_export.py @@ -0,0 +1,131 @@ +""" +Unit testing for BOM export functionality +""" + +from django.test import TestCase + +from django.urls import reverse +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group + + +class BomExportTest(TestCase): + + fixtures = [ + 'category', + 'part', + 'location', + 'bom', + ] + + def setUp(self): + super().setUp() + + # Create a user + user = get_user_model() + + self.user = user.objects.create_user( + username='username', + email='user@email.com', + password='password' + ) + + # Put the user into a group with the correct permissions + group = Group.objects.create(name='mygroup') + self.user.groups.add(group) + + # Give the group *all* the permissions! + for rule in group.rule_sets.all(): + rule.can_view = True + rule.can_change = True + rule.can_add = True + rule.can_delete = True + + rule.save() + + self.client.login(username='username', password='password') + + self.url = reverse('bom-download', kwargs={'pk': 100}) + + def test_export_csv(self): + """ + Test BOM download in CSV format + """ + + print("URL", self.url) + + params = { + 'file_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"') + + def test_export_xls(self): + """ + Test BOM download in XLS format + """ + + params = { + 'file_format': 'xls', + '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.xls"') + + def test_export_xlsx(self): + """ + Test BOM download in XLSX format + """ + + params = { + 'file_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) + + def test_export_json(self): + """ + Test BOM download in JSON format + """ + + params = { + 'file_format': 'json', + 'cascade': True, + 'parameter_data': True, + 'stock_data': True, + 'supplier_data': True, + 'manufacturer_data': True, + } + + response = self.client.get(self.url, data=params) + + self.assertEqual(response.status_code, 200) + + content = response.headers['Content-Disposition'] + self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.json"') diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 6bd8d02601..1fa7227f5e 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -48,7 +48,6 @@ part_detail_urls = [ url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'), - url(r'^params/', views.PartDetail.as_view(template_name='part/params.html'), name='part-params'), url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'), url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'), url(r'^allocation/?', views.PartDetail.as_view(template_name='part/allocation.html'), name='part-allocation'), @@ -56,15 +55,12 @@ part_detail_urls = [ url(r'^build/?', views.PartDetail.as_view(template_name='part/build.html'), name='part-build'), url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'), url(r'^prices/', views.PartPricingView.as_view(template_name='part/prices.html'), name='part-prices'), - url(r'^manufacturers/?', views.PartDetail.as_view(template_name='part/manufacturer.html'), name='part-manufacturers'), url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'), url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'), url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'), url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'), url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'), url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'), - url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'), - url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'), url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index d349557816..4acf5fcdb6 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -13,7 +13,7 @@ from django.shortcuts import get_object_or_404 from django.shortcuts import HttpResponseRedirect from django.utils.translation import gettext_lazy as _ from django.urls import reverse, reverse_lazy -from django.views.generic import DetailView, ListView, FormView, UpdateView +from django.views.generic import DetailView, ListView from django.forms.models import model_to_dict from django.forms import HiddenInput, CheckboxInput from django.conf import settings @@ -42,13 +42,14 @@ from common.models import InvenTreeSetting from company.models import SupplierPart from common.files import FileManager from common.views import FileManagementFormView, FileManagementAjaxView +from common.forms import UploadFileForm, MatchFieldForm from stock.models import StockLocation import common.settings as inventree_settings from . import forms as part_forms -from .bom import MakeBomTemplate, BomUploadManager, ExportBom, IsValidBOMFormat +from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat from order.models import PurchaseOrderLineItem from .admin import PartResource @@ -746,40 +747,6 @@ class PartImportAjax(FileManagementAjaxView, PartImport): return PartImport.validate(self, self.steps.current, form, **kwargs) -class PartNotes(UpdateView): - """ View for editing the 'notes' field of a Part object. - Presents a live markdown editor. - """ - - context_object_name = 'part' - # form_class = part_forms.EditNotesForm - template_name = 'part/notes.html' - model = Part - - role_required = 'part.change' - - fields = ['notes'] - - def get_success_url(self): - """ Return the success URL for this form """ - - return reverse('part-notes', kwargs={'pk': self.get_object().id}) - - def get_context_data(self, **kwargs): - - part = self.get_object() - - context = super().get_context_data(**kwargs) - - context['editing'] = str2bool(self.request.GET.get('edit', '')) - - ctx = part.get_context_data(self.request) - - context.update(ctx) - - return context - - class PartDetail(InvenTreeRoleMixin, DetailView): """ Detail view for Part object """ @@ -1245,7 +1212,7 @@ class BomValidate(AjaxUpdateView): } -class BomUpload(InvenTreeRoleMixin, FormView): +class BomUpload(InvenTreeRoleMixin, FileManagementFormView): """ View for uploading a BOM file, and handling BOM data importing. The BOM upload process is as follows: @@ -1272,184 +1239,116 @@ class BomUpload(InvenTreeRoleMixin, FormView): During these steps, data are passed between the server/client as JSON objects. """ - template_name = 'part/bom_upload/upload_file.html' - - # Context data passed to the forms (initially empty, extracted from uploaded file) - bom_headers = [] - bom_columns = [] - bom_rows = [] - missing_columns = [] - allowed_parts = [] - role_required = ('part.change', 'part.add') - def get_success_url(self): - part = self.get_object() - return reverse('upload-bom', kwargs={'pk': part.id}) + class BomFileManager(FileManager): + # Fields which are absolutely necessary for valid upload + REQUIRED_HEADERS = [ + 'Quantity' + ] - def get_form_class(self): + # Fields which are used for part matching (only one of them is needed) + ITEM_MATCH_HEADERS = [ + 'Part_Name', + 'Part_IPN', + 'Part_ID', + ] - # Default form is the starting point - return part_forms.BomUploadSelectFile + # Fields which would be helpful but are not required + OPTIONAL_HEADERS = [ + 'Reference', + 'Note', + 'Overage', + ] - def get_context_data(self, *args, **kwargs): + EDITABLE_HEADERS = [ + 'Reference', + 'Note', + 'Overage' + ] - ctx = super().get_context_data(*args, **kwargs) + name = 'order' + form_list = [ + ('upload', UploadFileForm), + ('fields', MatchFieldForm), + ('items', part_forms.BomMatchItemForm), + ] + form_steps_template = [ + 'part/bom_upload/upload_file.html', + 'part/bom_upload/match_fields.html', + 'part/bom_upload/match_parts.html', + ] + form_steps_description = [ + _("Upload File"), + _("Match Fields"), + _("Match Parts"), + ] + form_field_map = { + 'item_select': 'part', + 'quantity': 'quantity', + 'overage': 'overage', + 'reference': 'reference', + 'note': 'note', + } + file_manager_class = BomFileManager - # Give each row item access to the column it is in - # This provides for much simpler template rendering + def get_part(self): + """ Get part or return 404 """ - rows = [] - for row in self.bom_rows: - row_data = row['data'] + return get_object_or_404(Part, pk=self.kwargs['pk']) - data = [] + def get_context_data(self, form, **kwargs): + """ Handle context data for order """ - for idx, item in enumerate(row_data): + context = super().get_context_data(form=form, **kwargs) - data.append({ - 'cell': item, - 'idx': idx, - 'column': self.bom_columns[idx] - }) + part = self.get_part() - rows.append({ - 'index': row.get('index', -1), - 'data': data, - 'part_match': row.get('part_match', None), - 'part_options': row.get('part_options', self.allowed_parts), + context.update({'part': part}) - # User-input (passed between client and server) - 'quantity': row.get('quantity', None), - 'description': row.get('description', ''), - 'part_name': row.get('part_name', ''), - 'part': row.get('part', None), - 'reference': row.get('reference', ''), - 'notes': row.get('notes', ''), - 'errors': row.get('errors', ''), - }) + return context - ctx['part'] = self.part - ctx['bom_headers'] = BomUploadManager.HEADERS - ctx['bom_columns'] = self.bom_columns - ctx['bom_rows'] = rows - ctx['missing_columns'] = self.missing_columns - ctx['allowed_parts_list'] = self.allowed_parts - - return ctx - - def getAllowedParts(self): + def get_allowed_parts(self): """ Return a queryset of parts which are allowed to be added to this BOM. """ - return self.part.get_allowed_bom_items() + return self.get_part().get_allowed_bom_items() - def get(self, request, *args, **kwargs): - """ Perform the initial 'GET' request. - - Initially returns a form for file upload """ - - self.request = request - - # A valid Part object must be supplied. This is the 'parent' part for the BOM - self.part = get_object_or_404(Part, pk=self.kwargs['pk']) - - self.form = self.get_form() - - form_class = self.get_form_class() - form = self.get_form(form_class) - return self.render_to_response(self.get_context_data(form=form)) - - def handleBomFileUpload(self): - """ Process a BOM file upload form. - - This function validates that the uploaded file was valid, - and contains tabulated data that can be extracted. - If the file does not satisfy these requirements, - the "upload file" form is again shown to the user. - """ - - bom_file = self.request.FILES.get('bom_file', None) - - manager = None - bom_file_valid = False - - if bom_file is None: - self.form.add_error('bom_file', _('No BOM file provided')) - else: - # Create a BomUploadManager object - will perform initial data validation - # (and raise a ValidationError if there is something wrong with the file) - try: - manager = BomUploadManager(bom_file) - bom_file_valid = True - except ValidationError as e: - errors = e.error_dict - - for k, v in errors.items(): - self.form.add_error(k, v) - - if bom_file_valid: - # BOM file is valid? Proceed to the next step! - form = None - self.template_name = 'part/bom_upload/select_fields.html' - - self.extractDataFromFile(manager) - else: - form = self.form - - return self.render_to_response(self.get_context_data(form=form)) - - def getColumnIndex(self, name): - """ Return the index of the column with the given name. - It named column is not found, return -1 - """ - - try: - idx = list(self.column_selections.values()).index(name) - except ValueError: - idx = -1 - - return idx - - def preFillSelections(self): + def get_field_selection(self): """ Once data columns have been selected, attempt to pre-select the proper data from the database. This function is called once the field selection has been validated. The pre-fill data are then passed through to the part selection form. """ + self.allowed_items = self.get_allowed_parts() + # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database - k_idx = self.getColumnIndex('Part_ID') - p_idx = self.getColumnIndex('Part_Name') - i_idx = self.getColumnIndex('Part_IPN') + k_idx = self.get_column_index('Part_ID') + p_idx = self.get_column_index('Part_Name') + i_idx = self.get_column_index('Part_IPN') - q_idx = self.getColumnIndex('Quantity') - r_idx = self.getColumnIndex('Reference') - o_idx = self.getColumnIndex('Overage') - n_idx = self.getColumnIndex('Note') + q_idx = self.get_column_index('Quantity') + r_idx = self.get_column_index('Reference') + o_idx = self.get_column_index('Overage') + n_idx = self.get_column_index('Note') - for row in self.bom_rows: + for row in self.rows: """ - Iterate through each row in the uploaded data, and see if we can match the row to a "Part" object in the database. - There are three potential ways to match, based on the uploaded data: - a) Use the PK (primary key) field for the part, uploaded in the "Part_ID" field b) Use the IPN (internal part number) field for the part, uploaded in the "Part_IPN" field c) Use the name of the part, uploaded in the "Part_Name" field - Notes: - If using the Part_ID field, we can do an exact match against the PK field - If using the Part_IPN field, we can do an exact match against the IPN field - If using the Part_Name field, we can use fuzzy string matching to match "close" values - We also extract other information from the row, for the other non-matched fields: - Quantity - Reference - Overage - Note - """ # Initially use a quantity of zero @@ -1459,42 +1358,55 @@ class BomUpload(InvenTreeRoleMixin, FormView): exact_match_part = None # A list of potential Part matches - part_options = self.allowed_parts + part_options = self.allowed_items # Check if there is a column corresponding to "quantity" if q_idx >= 0: - q_val = row['data'][q_idx] + q_val = row['data'][q_idx]['cell'] if q_val: + # Delete commas + q_val = q_val.replace(',', '') + try: # Attempt to extract a valid quantity from the field quantity = Decimal(q_val) + # Store the 'quantity' value + row['quantity'] = quantity except (ValueError, InvalidOperation): pass - # Store the 'quantity' value - row['quantity'] = quantity - # Check if there is a column corresponding to "PK" if k_idx >= 0: - pk = row['data'][k_idx] + pk = row['data'][k_idx]['cell'] if pk: try: # Attempt Part lookup based on PK value - exact_match_part = Part.objects.get(pk=pk) + exact_match_part = self.allowed_items.get(pk=pk) except (ValueError, Part.DoesNotExist): exact_match_part = None - # Check if there is a column corresponding to "Part Name" - if p_idx >= 0: - part_name = row['data'][p_idx] + # Check if there is a column corresponding to "Part IPN" and no exact match found yet + if i_idx >= 0 and not exact_match_part: + part_ipn = row['data'][i_idx]['cell'] + + if part_ipn: + part_matches = [part for part in self.allowed_items if part.IPN and part_ipn.lower() == str(part.IPN.lower())] + + # Check for single match + if len(part_matches) == 1: + exact_match_part = part_matches[0] + + # Check if there is a column corresponding to "Part Name" and no exact match found yet + if p_idx >= 0 and not exact_match_part: + part_name = row['data'][p_idx]['cell'] row['part_name'] = part_name matches = [] - for part in self.allowed_parts: + for part in self.allowed_items: ratio = fuzz.partial_ratio(part.name + part.description, part_name) matches.append({'part': part, 'match': ratio}) @@ -1503,390 +1415,67 @@ class BomUpload(InvenTreeRoleMixin, FormView): matches = sorted(matches, key=lambda item: item['match'], reverse=True) part_options = [m['part'] for m in matches] + + # Supply list of part options for each row, sorted by how closely they match the part name + row['item_options'] = part_options - # Check if there is a column corresponding to "Part IPN" - if i_idx >= 0: - row['part_ipn'] = row['data'][i_idx] + # Unless found, the 'item_match' is blank + row['item_match'] = None + + if exact_match_part: + # If there is an exact match based on PK or IPN, use that + row['item_match'] = exact_match_part # Check if there is a column corresponding to "Overage" field if o_idx >= 0: - row['overage'] = row['data'][o_idx] + row['overage'] = row['data'][o_idx]['cell'] # Check if there is a column corresponding to "Reference" field if r_idx >= 0: - row['reference'] = row['data'][r_idx] + row['reference'] = row['data'][r_idx]['cell'] # Check if there is a column corresponding to "Note" field if n_idx >= 0: - row['note'] = row['data'][n_idx] - - # Supply list of part options for each row, sorted by how closely they match the part name - row['part_options'] = part_options - - # Unless found, the 'part_match' is blank - row['part_match'] = None - - if exact_match_part: - # If there is an exact match based on PK, use that - row['part_match'] = exact_match_part - else: - # Otherwise, check to see if there is a matching IPN - try: - if row['part_ipn']: - part_matches = [part for part in self.allowed_parts if part.IPN and row['part_ipn'].lower() == str(part.IPN.lower())] - - # Check for single match - if len(part_matches) == 1: - row['part_match'] = part_matches[0] - - continue - except KeyError: - pass - - def extractDataFromFile(self, bom): - """ Read data from the BOM file """ - - self.bom_columns = bom.columns() - self.bom_rows = bom.rows() - - def getTableDataFromPost(self): - """ Extract table cell data from POST request. - These data are used to maintain state between sessions. - - Table data keys are as follows: - - col_name_ - Column name at idx as provided in the uploaded file - col_guess_ - Column guess at idx as selected in the BOM - row__col - Cell data as provided in the uploaded file - - """ - - # Map the columns - self.column_names = {} - self.column_selections = {} - - self.row_data = {} - - for item in self.request.POST: - value = self.request.POST[item] - - # Column names as passed as col_name_ where idx is an integer - - # Extract the column names - if item.startswith('col_name_'): - try: - col_id = int(item.replace('col_name_', '')) - except ValueError: - continue - col_name = value - - self.column_names[col_id] = col_name - - # Extract the column selections (in the 'select fields' view) - if item.startswith('col_guess_'): - - try: - col_id = int(item.replace('col_guess_', '')) - except ValueError: - continue - - col_name = value - - self.column_selections[col_id] = value - - # Extract the row data - if item.startswith('row_'): - # Item should be of the format row__col_ - s = item.split('_') - - if len(s) < 4: - continue - - # Ignore row/col IDs which are not correct numeric values - try: - row_id = int(s[1]) - col_id = int(s[3]) - except ValueError: - continue - - if row_id not in self.row_data: - self.row_data[row_id] = {} - - self.row_data[row_id][col_id] = value - - self.col_ids = sorted(self.column_names.keys()) - - # Re-construct the data table - self.bom_rows = [] - - for row_idx in sorted(self.row_data.keys()): - row = self.row_data[row_idx] - items = [] - - for col_idx in sorted(row.keys()): - - value = row[col_idx] - items.append(value) - - self.bom_rows.append({ - 'index': row_idx, - 'data': items, - 'errors': {}, - }) - - # Construct the column data - self.bom_columns = [] - - # Track any duplicate column selections - self.duplicates = False - - for col in self.col_ids: - - if col in self.column_selections: - guess = self.column_selections[col] - else: - guess = None - - header = ({ - 'name': self.column_names[col], - 'guess': guess - }) - - if guess: - n = list(self.column_selections.values()).count(self.column_selections[col]) - if n > 1: - header['duplicate'] = True - self.duplicates = True - - self.bom_columns.append(header) - - # Are there any missing columns? - self.missing_columns = [] - - # Check that all required fields are present - for col in BomUploadManager.REQUIRED_HEADERS: - if col not in self.column_selections.values(): - self.missing_columns.append(col) - - # Check that at least one of the part match field is present - part_match_found = False - for col in BomUploadManager.PART_MATCH_HEADERS: - if col in self.column_selections.values(): - part_match_found = True - break - - # If not, notify user - if not part_match_found: - for col in BomUploadManager.PART_MATCH_HEADERS: - self.missing_columns.append(col) - - def handleFieldSelection(self): - """ Handle the output of the field selection form. - Here the user is presented with the raw data and must select the - column names and which rows to process. - """ - - # Extract POST data - self.getTableDataFromPost() - - valid = len(self.missing_columns) == 0 and not self.duplicates - - if valid: - # Try to extract meaningful data - self.preFillSelections() - self.template_name = 'part/bom_upload/select_parts.html' - else: - self.template_name = 'part/bom_upload/select_fields.html' - - return self.render_to_response(self.get_context_data(form=None)) - - def handlePartSelection(self): - - # Extract basic table data from POST request - self.getTableDataFromPost() - - # Keep track of the parts that have been selected - parts = {} - - # Extract other data (part selections, etc) - for key in self.request.POST: - value = self.request.POST[key] - - # Extract quantity from each row - if key.startswith('quantity_'): - try: - row_id = int(key.replace('quantity_', '')) - - row = self.getRowByIndex(row_id) - - if row is None: - continue - - q = Decimal(1) - - try: - q = Decimal(value) - if q < 0: - row['errors']['quantity'] = _('Quantity must be greater than zero') - - if 'part' in row.keys(): - if row['part'].trackable: - # Trackable parts must use integer quantities - if not q == int(q): - row['errors']['quantity'] = _('Quantity must be integer value for trackable parts') - - except (ValueError, InvalidOperation): - row['errors']['quantity'] = _('Enter a valid quantity') - - row['quantity'] = q - - except ValueError: - continue - - # Extract part from each row - if key.startswith('part_'): - - try: - row_id = int(key.replace('part_', '')) - - row = self.getRowByIndex(row_id) - - if row is None: - continue - except ValueError: - # Row ID non integer value - continue - - try: - part_id = int(value) - part = Part.objects.get(id=part_id) - except ValueError: - row['errors']['part'] = _('Select valid part') - continue - except Part.DoesNotExist: - row['errors']['part'] = _('Select valid part') - continue - - # Keep track of how many of each part we have seen - if part_id in parts: - parts[part_id]['quantity'] += 1 - row['errors']['part'] = _('Duplicate part selected') - else: - parts[part_id] = { - 'part': part, - 'quantity': 1, - } - - row['part'] = part - - if part.trackable: - # For trackable parts, ensure the quantity is an integer value! - if 'quantity' in row.keys(): - q = row['quantity'] - - if not q == int(q): - row['errors']['quantity'] = _('Quantity must be integer value for trackable parts') - - # Extract other fields which do not require further validation - for field in ['reference', 'notes']: - if key.startswith(field + '_'): - try: - row_id = int(key.replace(field + '_', '')) - - row = self.getRowByIndex(row_id) - - if row: - row[field] = value - except: - continue - - # Are there any errors after form handling? - valid = True - - for row in self.bom_rows: - # Has a part been selected for the given row? - part = row.get('part', None) - - if part is None: - row['errors']['part'] = _('Select a part') - else: - # Will the selected part result in a recursive BOM? - try: - part.checkAddToBOM(self.part) - except ValidationError: - row['errors']['part'] = _('Selected part creates a circular BOM') - - # Has a quantity been specified? - if row.get('quantity', None) is None: - row['errors']['quantity'] = _('Specify quantity') - - errors = row.get('errors', []) - - if len(errors) > 0: - valid = False - - self.template_name = 'part/bom_upload/select_parts.html' - - ctx = self.get_context_data(form=None) - - if valid: - self.part.clear_bom() - - # Generate new BOM items - for row in self.bom_rows: - part = row.get('part') - quantity = row.get('quantity') - reference = row.get('reference', '') - notes = row.get('notes', '') - - # Create a new BOM item! - item = BomItem( - part=self.part, - sub_part=part, - quantity=quantity, - reference=reference, - note=notes - ) - + row['note'] = row['data'][n_idx]['cell'] + + def done(self, form_list, **kwargs): + """ Once all the data is in, process it to add BomItem instances to the part """ + + self.part = self.get_part() + items = self.get_clean_items() + + # Clear BOM + self.part.clear_bom() + + # Generate new BOM items + for bom_item in items.values(): + try: + part = Part.objects.get(pk=int(bom_item.get('part'))) + except (ValueError, Part.DoesNotExist): + continue + + quantity = bom_item.get('quantity') + overage = bom_item.get('overage', '') + reference = bom_item.get('reference', '') + note = bom_item.get('note', '') + + # Create a new BOM item + item = BomItem( + part=self.part, + sub_part=part, + quantity=quantity, + overage=overage, + reference=reference, + note=note, + ) + + try: item.save() + except IntegrityError: + # BomItem already exists + pass - # Redirect to the BOM view - return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.part.id})) - else: - ctx['form_errors'] = True - - return self.render_to_response(ctx) - - def getRowByIndex(self, idx): - - for row in self.bom_rows: - if row['index'] == idx: - return row - - return None - - def post(self, request, *args, **kwargs): - """ Perform the various 'POST' requests required. - """ - - self.request = request - - self.part = get_object_or_404(Part, pk=self.kwargs['pk']) - self.allowed_parts = self.getAllowedParts() - self.form = self.get_form(self.get_form_class()) - - # Did the user POST a file named bom_file? - - form_step = request.POST.get('form_step', None) - - if form_step == 'select_file': - return self.handleBomFileUpload() - elif form_step == 'select_fields': - return self.handleFieldSelection() - elif form_step == 'select_parts': - return self.handlePartSelection() - - return self.render_to_response(self.get_context_data(form=self.form)) + return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.kwargs['pk']})) class PartExport(AjaxView): diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 4a6e7111e8..08e948607a 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -120,9 +120,6 @@ class StockAdjust(APIView): - StockAdd: add stock items - StockRemove: remove stock items - StockTransfer: transfer stock items - - # TODO - This needs serious refactoring!!! - """ queryset = StockItem.objects.none() @@ -143,7 +140,10 @@ class StockAdjust(APIView): elif 'items' in request.data: _items = request.data['items'] else: - raise ValidationError({'items': 'Request must contain list of stock items'}) + _items = [] + + if len(_items) == 0: + raise ValidationError(_('Request must contain list of stock items')) # List of validated items self.items = [] @@ -151,13 +151,22 @@ class StockAdjust(APIView): for entry in _items: if not type(entry) == dict: - raise ValidationError({'error': 'Improperly formatted data'}) + raise ValidationError(_('Improperly formatted data')) + + # Look for a 'pk' value (use 'id' as a backup) + pk = entry.get('pk', entry.get('id', None)) + + try: + pk = int(pk) + except (ValueError, TypeError): + raise ValidationError(_('Each entry must contain a valid integer primary-key')) try: - pk = entry.get('pk', None) item = StockItem.objects.get(pk=pk) - except (ValueError, StockItem.DoesNotExist): - raise ValidationError({'pk': 'Each entry must contain a valid pk field'}) + except (StockItem.DoesNotExist): + raise ValidationError({ + pk: [_('Primary key does not match valid stock item')] + }) if self.allow_missing_quantity and 'quantity' not in entry: entry['quantity'] = item.quantity @@ -165,16 +174,21 @@ class StockAdjust(APIView): try: quantity = Decimal(str(entry.get('quantity', None))) except (ValueError, TypeError, InvalidOperation): - raise ValidationError({'quantity': "Each entry must contain a valid quantity value"}) + raise ValidationError({ + pk: [_('Invalid quantity value')] + }) if quantity < 0: - raise ValidationError({'quantity': 'Quantity field must not be less than zero'}) + raise ValidationError({ + pk: [_('Quantity must not be less than zero')] + }) self.items.append({ 'item': item, 'quantity': quantity }) + # Extract 'notes' field self.notes = str(request.data.get('notes', '')) @@ -228,6 +242,11 @@ class StockRemove(StockAdjust): for item in self.items: + if item['quantity'] > item['item'].quantity: + raise ValidationError({ + item['item'].pk: [_('Specified quantity exceeds stock quantity')] + }) + if item['item'].take_stock(item['quantity'], request.user, notes=self.notes): n += 1 @@ -243,19 +262,24 @@ class StockTransfer(StockAdjust): def post(self, request, *args, **kwargs): - self.get_items(request) - data = request.data try: location = StockLocation.objects.get(pk=data.get('location', None)) except (ValueError, StockLocation.DoesNotExist): - raise ValidationError({'location': 'Valid location must be specified'}) + raise ValidationError({'location': [_('Valid location must be specified')]}) n = 0 + self.get_items(request) + for item in self.items: + if item['quantity'] > item['item'].quantity: + raise ValidationError({ + item['item'].pk: [_('Specified quantity exceeds stock quantity')] + }) + # If quantity is not specified, move the entire stock if item['quantity'] in [0, None]: item['quantity'] = item['item'].quantity @@ -454,13 +478,6 @@ class StockList(generics.ListCreateAPIView): - GET: Return a list of all StockItem objects (with optional query filters) - POST: Create a new StockItem - - Additional query parameters are available: - - location: Filter stock by location - - category: Filter by parts belonging to a certain category - - supplier: Filter by supplier - - ancestor: Filter by an 'ancestor' StockItem - - status: Filter by the StockItem status """ serializer_class = StockItemSerializer @@ -482,7 +499,6 @@ class StockList(generics.ListCreateAPIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - # TODO - Save the user who created this item item = serializer.save() # A location was *not* specified - try to infer it @@ -1092,47 +1108,41 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = LocationSerializer -stock_endpoints = [ - url(r'^$', StockDetail.as_view(), name='api-stock-detail'), -] - -location_endpoints = [ - url(r'^(?P\d+)/', LocationDetail.as_view(), name='api-location-detail'), - - url(r'^.*$', StockLocationList.as_view(), name='api-location-list'), -] - stock_api_urls = [ - url(r'location/', include(location_endpoints)), + url(r'^location/', include([ + url(r'^(?P\d+)/', LocationDetail.as_view(), name='api-location-detail'), + url(r'^.*$', StockLocationList.as_view(), name='api-location-list'), + ])), - # These JSON endpoints have been replaced (for now) with server-side form rendering - 02/06/2019 - # TODO: Remove server-side forms for stock adjustment!!! - url(r'count/?', StockCount.as_view(), name='api-stock-count'), - url(r'add/?', StockAdd.as_view(), name='api-stock-add'), - url(r'remove/?', StockRemove.as_view(), name='api-stock-remove'), - url(r'transfer/?', StockTransfer.as_view(), name='api-stock-transfer'), + # Endpoints for bulk stock adjustment actions + url(r'^count/', StockCount.as_view(), name='api-stock-count'), + url(r'^add/', StockAdd.as_view(), name='api-stock-add'), + url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'), + url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'), - # Base URL for StockItemAttachment API endpoints + # StockItemAttachment API endpoints url(r'^attachment/', include([ url(r'^(?P\d+)/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'), url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'), ])), - # Base URL for StockItemTestResult API endpoints + # StockItemTestResult API endpoints url(r'^test/', include([ url(r'^(?P\d+)/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'), url(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'), ])), + # StockItemTracking API endpoints url(r'^track/', include([ url(r'^(?P\d+)/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'), url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'), ])), - url(r'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'), + url(r'^tree/', StockCategoryTree.as_view(), name='api-stock-tree'), # Detail for a single stock item - url(r'^(?P\d+)/', include(stock_endpoints)), + url(r'^(?P\d+)/', StockDetail.as_view(), name='api-stock-detail'), + # Anything else url(r'^.*$', StockList.as_view(), name='api-stock-list'), ] diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index ec3eee09d5..0061bbb984 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -328,50 +328,6 @@ class UninstallStockForm(forms.ModelForm): ] -class AdjustStockForm(forms.ModelForm): - """ Form for performing simple stock adjustments. - - - Add stock - - Remove stock - - Count stock - - Move stock - - This form is used for managing stock adjuments for single or multiple stock items. - """ - - destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination stock location')) - - note = forms.CharField(label=_('Notes'), required=True, help_text=_('Add note (required)')) - - # transaction = forms.BooleanField(required=False, initial=False, label='Create Transaction', help_text='Create a stock transaction for these parts') - - confirm = forms.BooleanField(required=False, initial=False, label=_('Confirm stock adjustment'), help_text=_('Confirm movement of stock items')) - - set_loc = forms.BooleanField(required=False, initial=False, label=_('Set Default Location'), help_text=_('Set the destination as the default location for selected parts')) - - class Meta: - model = StockItem - - fields = [ - 'destination', - 'note', - # 'transaction', - 'confirm', - ] - - -class EditStockItemStatusForm(HelperForm): - """ - Simple form for editing StockItem status field - """ - - class Meta: - model = StockItem - fields = [ - 'status', - ] - - class EditStockItemForm(HelperForm): """ Form for editing a StockItem object. Note that not all fields can be edited here (even if they can be specified during creation. diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 2eb7695498..979c54ba28 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -534,14 +534,21 @@ $("#barcode-scan-into-location").click(function() { }); function itemAdjust(action) { - launchModalForm("/stock/adjust/", + + inventreeGet( + '{% url "api-stock-detail" item.pk %}', { - data: { - action: action, - item: {{ item.id }}, - }, - reload: true, - follow: true, + part_detail: true, + location_detail: true, + }, + { + success: function(item) { + adjustStock(action, [item], { + onSuccess: function() { + location.reload(); + } + }); + } } ); } diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 6a69be260e..d70d9a44be 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -59,11 +59,23 @@ {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} {% if roles.stock.change %} {% endif %} {% if roles.stock_location.change %} @@ -215,14 +227,34 @@ }); {% if location %} - $("#location-count").click(function() { - launchModalForm("/stock/adjust/", { - data: { - action: "count", + + function adjustLocationStock(action) { + inventreeGet( + '{% url "api-stock-list" %}', + { location: {{ location.id }}, - reload: true, + in_stock: true, + part_detail: true, + location_detail: true, + }, + { + success: function(items) { + adjustStock(action, items, { + onSuccess: function() { + location.reload(); + } + }); + } } - }); + ); + } + + $("#location-count").click(function() { + adjustLocationStock('count'); + }); + + $("#location-move").click(function() { + adjustLocationStock('move'); }); $('#print-label').click(function() { diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 729bf25a9b..74f9505c4a 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -7,8 +7,8 @@ from __future__ import unicode_literals from datetime import datetime, timedelta -from rest_framework import status from django.urls import reverse +from rest_framework import status from InvenTree.status_codes import StockStatus from InvenTree.api_tester import InvenTreeAPITestCase @@ -456,30 +456,32 @@ class StocktakeTest(StockAPITestCase): # POST without a PK response = self.post(url, data) - self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains(response, 'must contain a valid integer primary-key', status_code=status.HTTP_400_BAD_REQUEST) - # POST with a PK but no quantity + # POST with an invalid PK data['items'] = [{ 'pk': 10 }] response = self.post(url, data) - self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains(response, 'does not match valid stock item', status_code=status.HTTP_400_BAD_REQUEST) + # POST with missing quantity value data['items'] = [{ 'pk': 1234 }] response = self.post(url, data) - self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST) + # POST with an invalid quantity value data['items'] = [{ 'pk': 1234, 'quantity': '10x0d' }] response = self.post(url, data) - self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST) data['items'] = [{ 'pk': 1234, diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index c565532739..9494598430 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -105,31 +105,6 @@ class StockItemTest(StockViewTestCase): response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertEqual(response.status_code, 200) - def test_adjust_items(self): - url = reverse('stock-adjust') - - # Move items - response = self.client.get(url, {'stock[]': [1, 2, 3, 4, 5], 'action': 'move'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Count part - response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Remove items - response = self.client.get(url, {'location': 1, 'action': 'take'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Add items - response = self.client.get(url, {'item': 1, 'action': 'add'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Blank response - response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # TODO - Tests for POST data - def test_edit_item(self): # Test edit view for StockItem response = self.client.get(reverse('stock-item-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index ac9474f805..67101c1f3b 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -64,8 +64,6 @@ stock_urls = [ url(r'^track/', include(stock_tracking_urls)), - url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'), - url(r'^export-options/?', views.StockExportOptions.as_view(), name='stock-export-options'), url(r'^export/?', views.StockExport.as_view(), name='stock-export'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index c74b0bb2fc..6b64e8b54a 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -91,20 +91,20 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView): data = super().get_context_data(**kwargs) if self.object.serialized: - serial_elem = {a.serial: a for a in self.object.part.stock_items.all() if a.serialized} - serials = [int(a) for a in serial_elem.keys()] + serial_elem = {int(a.serial): a for a in self.object.part.stock_items.all() if a.serialized} + serials = serial_elem.keys() current = int(self.object.serial) # previous for nbr in range(current - 1, -1, -1): if nbr in serials: - data['previous'] = serial_elem.get(str(nbr), None) + data['previous'] = serial_elem.get(nbr, None) break # next for nbr in range(current + 1, max(serials) + 1): if nbr in serials: - data['next'] = serial_elem.get(str(nbr), None) + data['next'] = serial_elem.get(nbr, None) break return data @@ -749,343 +749,6 @@ class StockItemUninstall(AjaxView, FormMixin): return context -class StockAdjust(AjaxView, FormMixin): - """ View for enacting simple stock adjustments: - - - Take items from stock - - Add items to stock - - Count items - - Move stock - - Delete stock items - - """ - - ajax_template_name = 'stock/stock_adjust.html' - ajax_form_title = _('Adjust Stock') - form_class = StockForms.AdjustStockForm - stock_items = [] - role_required = 'stock.change' - - def get_GET_items(self): - """ Return list of stock items initally requested using GET. - - Items can be retrieved by: - - a) List of stock ID - stock[]=1,2,3,4,5 - b) Parent part - part=3 - c) Parent location - location=78 - d) Single item - item=2 - """ - - # Start with all 'in stock' items - items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) - - # Client provides a list of individual stock items - if 'stock[]' in self.request.GET: - items = items.filter(id__in=self.request.GET.getlist('stock[]')) - - # Client provides a PART reference - elif 'part' in self.request.GET: - items = items.filter(part=self.request.GET.get('part')) - - # Client provides a LOCATION reference - elif 'location' in self.request.GET: - items = items.filter(location=self.request.GET.get('location')) - - # Client provides a single StockItem lookup - elif 'item' in self.request.GET: - items = [StockItem.objects.get(id=self.request.GET.get('item'))] - - # Unsupported query (no items) - else: - items = [] - - for item in items: - - # Initialize quantity to zero for addition/removal - if self.stock_action in ['take', 'add']: - item.new_quantity = 0 - # Initialize quantity at full amount for counting or moving - else: - item.new_quantity = item.quantity - - return items - - def get_POST_items(self): - """ Return list of stock items sent back by client on a POST request """ - - items = [] - - for item in self.request.POST: - if item.startswith('stock-id-'): - - pk = item.replace('stock-id-', '') - q = self.request.POST[item] - - try: - stock_item = StockItem.objects.get(pk=pk) - except StockItem.DoesNotExist: - continue - - stock_item.new_quantity = q - - items.append(stock_item) - - return items - - def get_stock_action_titles(self): - - # Choose form title and action column based on the action - titles = { - 'move': [_('Move Stock Items'), _('Move')], - 'count': [_('Count Stock Items'), _('Count')], - 'take': [_('Remove From Stock'), _('Take')], - 'add': [_('Add Stock Items'), _('Add')], - 'delete': [_('Delete Stock Items'), _('Delete')], - } - - self.ajax_form_title = titles[self.stock_action][0] - self.stock_action_title = titles[self.stock_action][1] - - def get_context_data(self): - - context = super().get_context_data() - - context['stock_items'] = self.stock_items - - context['stock_action'] = self.stock_action.strip().lower() - - self.get_stock_action_titles() - context['stock_action_title'] = self.stock_action_title - - # Quantity column will be read-only in some circumstances - context['edit_quantity'] = not self.stock_action == 'delete' - - return context - - def get_form(self): - - form = super().get_form() - - if not self.stock_action == 'move': - form.fields.pop('destination') - form.fields.pop('set_loc') - - return form - - def get(self, request, *args, **kwargs): - - self.request = request - - # Action - self.stock_action = request.GET.get('action', '').lower() - - # Pick a default action... - if self.stock_action not in ['move', 'count', 'take', 'add', 'delete']: - self.stock_action = 'count' - - # Save list of items! - self.stock_items = self.get_GET_items() - - return self.renderJsonResponse(request, self.get_form()) - - def post(self, request, *args, **kwargs): - - self.request = request - - self.stock_action = request.POST.get('stock_action', 'invalid').strip().lower() - - # Update list of stock items - self.stock_items = self.get_POST_items() - - form = self.get_form() - - valid = form.is_valid() - - for item in self.stock_items: - - try: - item.new_quantity = Decimal(item.new_quantity) - except ValueError: - item.error = _('Must enter integer value') - valid = False - continue - - if item.new_quantity < 0: - item.error = _('Quantity must be positive') - valid = False - continue - - if self.stock_action in ['move', 'take']: - - if item.new_quantity > item.quantity: - item.error = _('Quantity must not exceed {x}').format(x=item.quantity) - valid = False - continue - - confirmed = str2bool(request.POST.get('confirm')) - - if not confirmed: - valid = False - form.add_error('confirm', _('Confirm stock adjustment')) - - data = { - 'form_valid': valid, - } - - if valid: - result = self.do_action(note=form.cleaned_data['note']) - - data['success'] = result - - # Special case - Single Stock Item - # If we deplete the stock item, we MUST redirect to a new view - single_item = len(self.stock_items) == 1 - - if result and single_item: - - # Was the entire stock taken? - item = self.stock_items[0] - - if item.quantity == 0: - # Instruct the form to redirect - data['url'] = reverse('stock-index') - - return self.renderJsonResponse(request, form, data=data, context=self.get_context_data()) - - def do_action(self, note=None): - """ Perform stock adjustment action """ - - if self.stock_action == 'move': - destination = None - - set_default_loc = str2bool(self.request.POST.get('set_loc', False)) - - try: - destination = StockLocation.objects.get(id=self.request.POST.get('destination')) - except StockLocation.DoesNotExist: - pass - except ValueError: - pass - - return self.do_move(destination, set_default_loc, note=note) - - elif self.stock_action == 'add': - return self.do_add(note=note) - - elif self.stock_action == 'take': - return self.do_take(note=note) - - elif self.stock_action == 'count': - return self.do_count(note=note) - - elif self.stock_action == 'delete': - return self.do_delete(note=note) - - else: - return _('No action performed') - - def do_add(self, note=None): - - count = 0 - - for item in self.stock_items: - if item.new_quantity <= 0: - continue - - item.add_stock(item.new_quantity, self.request.user, notes=note) - - count += 1 - - return _('Added stock to {n} items').format(n=count) - - def do_take(self, note=None): - - count = 0 - - for item in self.stock_items: - if item.new_quantity <= 0: - continue - - item.take_stock(item.new_quantity, self.request.user, notes=note) - - count += 1 - - return _('Removed stock from {n} items').format(n=count) - - def do_count(self, note=None): - - count = 0 - - for item in self.stock_items: - - item.stocktake(item.new_quantity, self.request.user, notes=note) - - count += 1 - - return _("Counted stock for {n} items".format(n=count)) - - def do_move(self, destination, set_loc=None, note=None): - """ Perform actual stock movement """ - - count = 0 - - for item in self.stock_items: - # Avoid moving zero quantity - if item.new_quantity <= 0: - continue - - # If we wish to set the destination location to the default one - if set_loc: - item.part.default_location = destination - item.part.save() - - # Do not move to the same location (unless the quantity is different) - if destination == item.location and item.new_quantity == item.quantity: - continue - - item.move(destination, note, self.request.user, quantity=item.new_quantity) - - count += 1 - - # Is ownership control enabled? - stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') - - if stock_ownership_control: - # Fetch destination owner - destination_owner = destination.owner - - if destination_owner: - # Update owner - item.owner = destination_owner - item.save() - - if count == 0: - return _('No items were moved') - - else: - return _('Moved {n} items to {dest}').format( - n=count, - dest=destination.pathstring) - - def do_delete(self): - """ Delete multiple stock items """ - - count = 0 - # note = self.request.POST['note'] - - for item in self.stock_items: - - # TODO - In the future, StockItems should not be 'deleted' - # TODO - Instead, they should be marked as "inactive" - - item.delete() - - count += 1 - - return _("Deleted {n} stock items").format(n=count) - - class StockItemEdit(AjaxUpdateView): """ View for editing details of a single StockItem diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 76104d8fe2..4818fda1c6 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -41,7 +41,6 @@ - diff --git a/InvenTree/templates/js/api.js b/InvenTree/templates/js/api.js index 5e8905a1dd..93fa5a41e4 100644 --- a/InvenTree/templates/js/api.js +++ b/InvenTree/templates/js/api.js @@ -1,3 +1,6 @@ +{% load i18n %} +{% load inventree_extras %} + var jQuery = window.$; // using jQuery @@ -138,4 +141,49 @@ function inventreeDelete(url, options={}) { inventreePut(url, {}, options); +} + + +function showApiError(xhr) { + + var title = null; + var message = null; + + switch (xhr.status) { + case 0: // No response + title = '{% trans "No Response" %}'; + message = '{% trans "No response from the InvenTree server" %}'; + break; + case 400: // Bad request + // Note: Normally error code 400 is handled separately, + // and should now be shown here! + title = '{% trans "Error 400: Bad request" %}'; + message = '{% trans "API request returned error code 400" %}'; + break; + case 401: // Not authenticated + title = '{% trans "Error 401: Not Authenticated" %}'; + message = '{% trans "Authentication credentials not supplied" %}'; + break; + case 403: // Permission denied + title = '{% trans "Error 403: Permission Denied" %}'; + message = '{% trans "You do not have the required permissions to access this function" %}'; + break; + case 404: // Resource not found + title = '{% trans "Error 404: Resource Not Found" %}'; + message = '{% trans "The requested resource could not be located on the server" %}'; + break; + case 408: // Timeout + title = '{% trans "Error 408: Timeout" %}'; + message = '{% trans "Connection timeout while requesting data from server" %}'; + break; + default: + title = '{% trans "Unhandled Error Code" %}'; + message = `{% trans "Error code" %}: ${xhr.status}`; + break; + } + + message += "
    "; + message += renderErrorMessage(xhr); + + showAlertDialog(title, message); } \ No newline at end of file diff --git a/InvenTree/templates/js/company.js b/InvenTree/templates/js/company.js index d28bca5547..18d7408b23 100644 --- a/InvenTree/templates/js/company.js +++ b/InvenTree/templates/js/company.js @@ -318,6 +318,12 @@ function loadManufacturerPartTable(table, url, options) { } } }, + { + field: 'description', + title: '{% trans "Description" %}', + sortable: false, + switchable: true, + } ], }); } @@ -550,6 +556,21 @@ function loadSupplierPartTable(table, url, options) { } } }, + { + field: 'description', + title: '{% trans "Description" %}', + sortable: false, + }, + { + field: 'note', + title: '{% trans "Notes" %}', + sortable: false, + }, + { + field: 'packaging', + title: '{% trans "Packaging" %}', + sortable: false, + } ], }); } \ No newline at end of file diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js index b7af665393..6dd7dbd968 100644 --- a/InvenTree/templates/js/forms.js +++ b/InvenTree/templates/js/forms.js @@ -353,12 +353,16 @@ function constructFormBody(fields, options) { // Override existing query filters (if provided!) fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters); + // TODO: Refactor the following code with Object.assign (see above) + // Secondary modal options fields[field].secondary = field_options.secondary; // Edit callback fields[field].onEdit = field_options.onEdit; + fields[field].multiline = field_options.multiline; + // Custom help_text if (field_options.help_text) { fields[field].help_text = field_options.help_text; @@ -395,11 +399,11 @@ function constructFormBody(fields, options) { for (var name in displayed_fields) { - // Only push names which are actually in the set of fields - if (name in fields) { - field_names.push(name); - } else { - console.log(`WARNING: '${name}' does not match a valid field name.`); + field_names.push(name); + + // Field not specified in the API, but the client wishes to add it! + if (!(name in fields)) { + fields[name] = displayed_fields[name]; } } @@ -422,10 +426,8 @@ function constructFormBody(fields, options) { default: break; } - - var f = constructField(name, field, options); - html += f; + html += constructField(name, field, options); } // TODO: Dynamically create the modals, @@ -441,7 +443,15 @@ function constructFormBody(fields, options) { modalEnable(modal, true); // Insert generated form content - $(modal).find('.modal-form-content').html(html); + $(modal).find('#form-content').html(html); + + if (options.preFormContent) { + $(modal).find('#pre-form-content').html(options.preFormContent); + } + + if (options.postFormContent) { + $(modal).find('#post-form-content').html(options.postFormContent); + } // Clear any existing buttons from the modal $(modal).find('#modal-footer-buttons').html(''); @@ -474,7 +484,21 @@ function constructFormBody(fields, options) { $(modal).on('click', '#modal-form-submit', function() { - submitFormData(fields, options); + // Immediately disable the "submit" button, + // to prevent the form being submitted multiple times! + $(options.modal).find('#modal-form-submit').prop('disabled', true); + + // Run custom code before normal form submission + if (options.beforeSubmit) { + options.beforeSubmit(fields, options); + } + + // Run custom code instead of normal form submission + if (options.onSubmit) { + options.onSubmit(fields, options); + } else { + submitFormData(fields, options); + } }); } @@ -511,10 +535,6 @@ function insertConfirmButton(options) { */ function submitFormData(fields, options) { - // Immediately disable the "submit" button, - // to prevent the form being submitted multiple times! - $(options.modal).find('#modal-form-submit').prop('disabled', true); - // Form data to be uploaded to the server // Only used if file / image upload is required var form_data = new FormData(); @@ -581,47 +601,9 @@ function submitFormData(fields, options) { case 400: // Bad request handleFormErrors(xhr.responseJSON, fields, options); break; - case 0: // No response - $(options.modal).modal('hide'); - showAlertDialog( - '{% trans "No Response" %}', - '{% trans "No response from the InvenTree server" %}', - ); - break; - case 401: // Not authenticated - $(options.modal).modal('hide'); - showAlertDialog( - '{% trans "Error 401: Not Authenticated" %}', - '{% trans "Authentication credentials not supplied" %}', - ); - break; - case 403: // Permission denied - $(options.modal).modal('hide'); - showAlertDialog( - '{% trans "Error 403: Permission Denied" %}', - '{% trans "You do not have the required permissions to access this function" %}', - ); - break; - case 404: // Resource not found - $(options.modal).modal('hide'); - showAlertDialog( - '{% trans "Error 404: Resource Not Found" %}', - '{% trans "The requested resource could not be located on the server" %}', - ); - break; - case 408: // Timeout - $(options.modal).modal('hide'); - showAlertDialog( - '{% trans "Error 408: Timeout" %}', - '{% trans "Connection timeout while requesting data from server" %}', - ); - break; default: $(options.modal).modal('hide'); - - showAlertDialog('{% trans "Error requesting form data" %}', renderErrorMessage(xhr)); - - console.log(`WARNING: Unhandled response code - ${xhr.status}`); + showApiError(xhr); break; } } @@ -697,6 +679,10 @@ function getFormFieldValue(name, field, options) { // Find the HTML element var el = $(options.modal).find(`#id_${name}`); + if (!el) { + return null; + } + var value = null; switch (field.type) { @@ -834,33 +820,27 @@ function handleFormErrors(errors, fields, options) { } for (field_name in errors) { - if (field_name in fields) { - // Add the 'has-error' class - $(options.modal).find(`#div_id_${field_name}`).addClass('has-error'); + // Add the 'has-error' class + $(options.modal).find(`#div_id_${field_name}`).addClass('has-error'); - var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`); + var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`); - var field_errors = errors[field_name]; + var field_errors = errors[field_name]; - // Add an entry for each returned error message - for (var idx = field_errors.length-1; idx >= 0; idx--) { + // Add an entry for each returned error message + for (var idx = field_errors.length-1; idx >= 0; idx--) { - var error_text = field_errors[idx]; + var error_text = field_errors[idx]; - var html = ` - - ${error_text} - `; + var html = ` + + ${error_text} + `; - field_dom.append(html); - } - - } else { - console.log(`WARNING: handleFormErrors found no match for field '${field_name}'`); + field_dom.append(html); } } - } @@ -1464,21 +1444,21 @@ function constructInputOptions(name, classes, type, parameters) { opts.push(`readonly=''`); } - if (parameters.value) { + if (parameters.value != null) { // Existing value? opts.push(`value='${parameters.value}'`); - } else if (parameters.default) { + } else if (parameters.default != null) { // Otherwise, a defualt value? opts.push(`value='${parameters.default}'`); } // Maximum input length - if (parameters.max_length) { + if (parameters.max_length != null) { opts.push(`maxlength='${parameters.max_length}'`); } // Minimum input length - if (parameters.min_length) { + if (parameters.min_length != null) { opts.push(`minlength='${parameters.min_length}'`); } @@ -1497,12 +1477,21 @@ function constructInputOptions(name, classes, type, parameters) { opts.push(`required=''`); } + // Custom mouseover title? + if (parameters.title != null) { + opts.push(`title='${parameters.title}'`); + } + // Placeholder? - if (parameters.placeholder) { + if (parameters.placeholder != null) { opts.push(`placeholder='${parameters.placeholder}'`); } - return ``; + if (parameters.multiline) { + return ``; + } else { + return ``; + } } diff --git a/InvenTree/templates/js/modals.js b/InvenTree/templates/js/modals.js index b613ed81f6..b404af364c 100644 --- a/InvenTree/templates/js/modals.js +++ b/InvenTree/templates/js/modals.js @@ -12,7 +12,6 @@ */ function createNewModal(options={}) { - var id = 1; // Check out what modal forms are already being displayed @@ -39,12 +38,13 @@ function createNewModal(options={}) {
    - - - - - - \ No newline at end of file + \ No newline at end of file