2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-30 04:26:44 +00:00

Merge remote-tracking branch 'inventree/master' into partial-shipment

# Conflicts:
#	InvenTree/InvenTree/version.py
#	InvenTree/order/models.py
This commit is contained in:
Oliver 2021-11-11 12:35:59 +11:00
commit d5cf2b08ac
120 changed files with 3551 additions and 1760 deletions

View File

@ -1,31 +1,47 @@
--- ---
name: Bug report name: Bug
about: Create a bug report to help us improve InvenTree about: Create a bug report to help us improve InvenTree!
title: "[BUG] Enter bug description" title: "[BUG] Enter bug description"
labels: bug, question labels: bug, question
assignees: '' assignees: ''
--- ---
**Describe the bug** <!---
A clear and concise description of what the bug is. Everything inside these brackets is hidden - please remove them where you fill out information.
--->
**Describe the bug**
<!---
A clear and concise description of what the bug is.
--->
**Steps to Reproduce**
**To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
<!---
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
4. See error 4. See error
--->
**Expected behavior** **Expected behavior**
<!---
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
--->
<!---
**Screenshots** **Screenshots**
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
--->
**Deployment Method** **Deployment Method**
Docker - [ ] Docker
Bare Metal - [ ] Bare Metal
**Version Information** **Version Information**
You can get this by going to the "About InvenTree" section in the upper right corner and cicking on to the "copy version information" <!---
You can get this by going to the "About InvenTree" section in the upper right corner and clicking on to the "copy version information"
--->

View File

@ -43,7 +43,6 @@ jobs:
run: | run: |
npm install markuplint npm install markuplint
npx markuplint InvenTree/build/templates/build/*.html npx markuplint InvenTree/build/templates/build/*.html
npx markuplint InvenTree/common/templates/common/*.html
npx markuplint InvenTree/company/templates/company/*.html npx markuplint InvenTree/company/templates/company/*.html
npx markuplint InvenTree/order/templates/order/*.html npx markuplint InvenTree/order/templates/order/*.html
npx markuplint InvenTree/part/templates/part/*.html npx markuplint InvenTree/part/templates/part/*.html

View File

@ -76,6 +76,12 @@ class InvenTreeConfig(AppConfig):
minutes=30, minutes=30,
) )
# Delete old notification records
InvenTree.tasks.schedule_task(
'common.tasks.delete_old_notifications',
schedule_type=Schedule.DAILY,
)
def update_exchange_rates(self): def update_exchange_rates(self):
""" """
Update exchange rates each time the server is started, *if*: Update exchange rates each time the server is started, *if*:

View File

@ -69,6 +69,35 @@ def getStaticUrl(filename):
return os.path.join(STATIC_URL, str(filename)) return os.path.join(STATIC_URL, str(filename))
def construct_absolute_url(*arg):
"""
Construct (or attempt to construct) an absolute URL from a relative URL.
This is useful when (for example) sending an email to a user with a link
to something in the InvenTree web framework.
This requires the BASE_URL configuration option to be set!
"""
base = str(InvenTreeSetting.get_setting('INVENTREE_BASE_URL'))
url = '/'.join(arg)
if not base:
return url
# Strip trailing slash from base url
if base.endswith('/'):
base = base[:-1]
if url.startswith('/'):
url = url[1:]
url = f"{base}/{url}"
return url
def getBlankImage(): def getBlankImage():
""" """
Return the qualified path for the 'blank image' placeholder. Return the qualified path for the 'blank image' placeholder.

View File

@ -17,7 +17,7 @@ from company.models import Company
from part.models import Part from part.models import Part
logger = logging.getLogger("inventree-thumbnails") logger = logging.getLogger('inventree')
class Command(BaseCommand): class Command(BaseCommand):

View File

@ -108,6 +108,13 @@ class InvenTreeMetadata(SimpleMetadata):
model_fields = model_meta.get_field_info(model_class) model_fields = model_meta.get_field_info(model_class)
model_default_func = getattr(model_class, 'api_defaults', None)
if model_default_func:
model_default_values = model_class.api_defaults(self.request)
else:
model_default_values = {}
# Iterate through simple fields # Iterate through simple fields
for name, field in model_fields.fields.items(): for name, field in model_fields.fields.items():
@ -123,6 +130,9 @@ class InvenTreeMetadata(SimpleMetadata):
serializer_info[name]['default'] = default serializer_info[name]['default'] = default
elif name in model_default_values:
serializer_info[name]['default'] = model_default_values[name]
# Iterate through relations # Iterate through relations
for name, relation in model_fields.relations.items(): for name, relation in model_fields.relations.items():
@ -141,6 +151,9 @@ class InvenTreeMetadata(SimpleMetadata):
if 'help_text' not in serializer_info[name] and hasattr(relation.model_field, 'help_text'): if 'help_text' not in serializer_info[name] and hasattr(relation.model_field, 'help_text'):
serializer_info[name]['help_text'] = relation.model_field.help_text serializer_info[name]['help_text'] = relation.model_field.help_text
if name in model_default_values:
serializer_info[name]['default'] = model_default_values[name]
except AttributeError: except AttributeError:
pass pass

View File

@ -15,30 +15,26 @@
} }
.login-screen { .login-screen {
background-image: url("/static/img/paper_splash.jpg"); background: url(/static/img/paper_splash.jpg) no-repeat center fixed;
background-size: cover; background-size: cover;
background-repeat: no-repeat; height: 100vh;
height: 100%;
font-family: 'Numans', sans-serif; font-family: 'Numans', sans-serif;
color: #eee; color: #eee;
} }
.login-container { .login-container {
left: 50%; align-self: center;
position: fixed;
top: 50%;
transform: translate(-50%, -50%);
width: 30%;
align-content: center;
border-radius: 15px; border-radius: 15px;
padding: 20px; padding: 20px;
padding-bottom: 35px; padding-bottom: 35px;
background-color: rgba(50, 50, 50, 0.75); background-color: rgba(50, 50, 50, 0.75);
width: 100%;
max-width: 550px;
margin: auto;
} }
.login-header { .login-header {
padding-right: 30px; margin-right: 5px;
margin-right: 30px;
} }
.login-container input { .login-container input {
@ -128,21 +124,24 @@
align-content: center; align-content: center;
} }
.qr-container {
width: 100%;
align-content: center;
object-fit: fill;
}
.navbar { .navbar {
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
background-color: var(--secondary-color); background-color: var(--secondary-color);
box-shadow: 0px 5px 5px rgb(0 0 0 / 5%);
}
.inventree-navbar-menu {
position: absolute !important;
} }
.navbar-brand { .navbar-brand {
float: left; float: left;
} }
.navbar-spacer {
height: 60px;
}
#navbar-barcode-li { #navbar-barcode-li {
border-left: none; border-left: none;
border-right: none; border-right: none;
@ -178,10 +177,6 @@
float: right; float: right;
} }
.starred-part {
color: #ffbb00;
}
.red-cell { .red-cell {
background-color: #ec7f7f; background-color: #ec7f7f;
} }
@ -297,8 +292,6 @@
vertical-align: middle; vertical-align: middle;
margin: 1px; margin: 1px;
padding: 2px; padding: 2px;
background: #eee;
border: 1px solid #eee;
border-radius: 3px; border-radius: 3px;
} }
@ -310,7 +303,13 @@
transform: translate(0%, -25%); transform: translate(0%, -25%);
} }
.filter-list .close:hover {background: #bbb;} .filter-list .close:hover {
background: #bbb;
}
.filter-list .form-control {
width: initial;
}
.filter-tag { .filter-tag {
display: inline-block; display: inline-block;
@ -318,8 +317,6 @@
zoom: 1; zoom: 1;
padding-left: 3px; padding-left: 3px;
padding-right: 3px; padding-right: 3px;
padding-top: 2px;
padding-bottom: 2px;
border: 1px solid #aaa; border: 1px solid #aaa;
border-radius: 3px; border-radius: 3px;
background: #eee; background: #eee;
@ -328,6 +325,12 @@
margin-right: 5px; margin-right: 5px;
} }
.filter-button {
padding: 2px;
padding-left: 4px;
padding-right: 4px;
}
.filter-input { .filter-input {
display: inline-block; display: inline-block;
*display: inline; *display: inline;
@ -539,7 +542,7 @@
.inventree-body { .inventree-body {
width: 100%; width: 100%;
padding: 5px; padding: 5px;
margin-top: 10px; padding-right: 0;
} }
.inventree-pre-content { .inventree-pre-content {
@ -556,8 +559,10 @@
transition: 0.1s; transition: 0.1s;
} }
.body { .search-autocomplete-item {
padding-top: 50px; border-top: 1px solid #EEE;
margin-bottom: 2px;
overflow-x: hidden;
} }
.modal { .modal {
@ -740,13 +745,7 @@ input[type="submit"] {
} }
.notification-area { .notification-area {
position: fixed; opacity: 0.8;
top: 0px;
margin-top: 20px;
width: 100%;
padding: 20px;
z-index: 5000;
pointer-events: none; /* Prevent this div from blocking links underneath */
} }
.notes { .notes {
@ -756,7 +755,6 @@ input[type="submit"] {
} }
.alert { .alert {
display: none;
border-radius: 5px; border-radius: 5px;
opacity: 0.9; opacity: 0.9;
pointer-events: all; pointer-events: all;
@ -766,9 +764,8 @@ input[type="submit"] {
display: block; display: block;
} }
.btn { .navbar .btn {
margin-left: 2px; margin-left: 5px;
margin-right: 2px;
} }
.btn-secondary { .btn-secondary {
@ -831,11 +828,12 @@ input[type="submit"] {
color: var(--bs-body-color); color: var(--bs-body-color);
background-color: var(--secondary-color); background-color: var(--secondary-color);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
box-shadow: 0px 5px 5px rgb(0 0 0 / 5%);
} }
.panel { .panel {
box-shadow: 2px 2px #DDD; box-shadow: 2px 2px #DDD;
margin-bottom: 20px; margin-bottom: .75rem;
background-color: #fff; background-color: #fff;
border: 1px solid #ccc; border: 1px solid #ccc;
} }

View File

@ -1,5 +1,3 @@
{% load inventree_extras %}
/* globals /* globals
ClipboardJS, ClipboardJS,
inventreeFormDataUpload, inventreeFormDataUpload,
@ -130,61 +128,79 @@ function inventreeDocReady() {
attachClipboard('.clip-btn-version', 'modal-about', 'about-copy-text'); attachClipboard('.clip-btn-version', 'modal-about', 'about-copy-text');
// Add autocomplete to the search-bar // Add autocomplete to the search-bar
$('#search-bar').autocomplete({ if ($('#search-bar').exists()) {
source: function(request, response) { $('#search-bar').autocomplete({
$.ajax({ source: function(request, response) {
url: '/api/part/',
data: { var params = {
search: request.term, search: request.term,
limit: user_settings.SEARCH_PREVIEW_RESULTS, limit: user_settings.SEARCH_PREVIEW_RESULTS,
offset: 0 offset: 0,
}, };
success: function(data) {
var transformed = $.map(data.results, function(el) { if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) {
return { // Limit to active parts
label: el.full_name, params.active = true;
id: el.pk,
thumbnail: el.thumbnail,
data: el,
};
});
response(transformed);
},
error: function() {
response([]);
}
});
},
create: function() {
$(this).data('ui-autocomplete')._renderItem = function(ul, item) {
var html = `<a href='/part/${item.id}/'><span>`;
html += `<img class='hover-img-thumb' src='`;
html += item.thumbnail || `/static/img/blank_image.png`;
html += `'> `;
html += item.label;
html += '</span>';
if (user_settings.SEARCH_SHOW_STOCK_LEVELS) {
html += partStockLabel(item.data);
} }
html += '</a>'; $.ajax({
url: '/api/part/',
data: params,
success: function(data) {
return $('<li>').append(html).appendTo(ul); var transformed = $.map(data.results, function(el) {
}; return {
}, label: el.full_name,
select: function( event, ui ) { id: el.pk,
window.location = '/part/' + ui.item.id + '/'; thumbnail: el.thumbnail,
}, data: el,
minLength: 2, };
classes: { });
'ui-autocomplete': 'dropdown-menu search-menu', response(transformed);
}, },
}); error: function() {
response([]);
}
});
},
create: function() {
$(this).data('ui-autocomplete')._renderItem = function(ul, item) {
var html = `
<div class='search-autocomplete-item' title='${item.data.description}'>
<a href='/part/${item.id}/'>
<span style='padding-right: 10px;'><img class='hover-img-thumb' src='${item.thumbnail || "/static/img/blank_image.png"}'> ${item.label}</span>
</a>
<span class='flex' style='flex-grow: 1;'></span>
`;
if (user_settings.SEARCH_SHOW_STOCK_LEVELS) {
html += partStockLabel(
item.data,
{
classes: 'badge-right',
}
);
}
html += '</div>';
return $('<li>').append(html).appendTo(ul);
};
},
select: function( event, ui ) {
window.location = '/part/' + ui.item.id + '/';
},
minLength: 2,
classes: {
'ui-autocomplete': 'dropdown-menu search-menu',
},
position: {
my : "right top",
at: "right bottom"
}
});
}
// Generate brand-icons // Generate brand-icons
$('.brand-icon').each(function(i, obj) { $('.brand-icon').each(function(i, obj) {
@ -197,6 +213,9 @@ function inventreeDocReady() {
location.href = url; location.href = url;
}); });
// Display any cached alert messages
showCachedAlerts();
} }
function isFileTransfer(transfer) { function isFileTransfer(transfer) {

View File

@ -1,44 +1,120 @@
function showAlert(target, message, timeout=5000) { /*
* Add a cached alert message to sesion storage
*/
function addCachedAlert(message, options={}) {
$(target).find(".alert-msg").html(message); var alerts = sessionStorage.getItem('inventree-alerts');
$(target).show();
$(target).delay(timeout).slideUp(200, function() { if (alerts) {
alerts = JSON.parse(alerts);
} else {
alerts = [];
}
alerts.push({
message: message,
style: options.style || 'success',
icon: options.icon,
});
sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts));
}
/*
* Remove all cached alert messages
*/
function clearCachedAlerts() {
sessionStorage.removeItem('inventree-alerts');
}
/*
* Display an alert, or cache to display on reload
*/
function showAlertOrCache(message, cache, options={}) {
if (cache) {
addCachedAlert(message, options);
} else {
showMessage(message, options);
}
}
/*
* Display cached alert messages when loading a page
*/
function showCachedAlerts() {
var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || [];
alerts.forEach(function(alert) {
showMessage(
alert.message,
{
style: alert.style || 'success',
icon: alert.icon,
}
);
});
clearCachedAlerts();
}
/*
* Display an alert message at the top of the screen.
* The message will contain a "close" button,
* and also dismiss automatically after a certain amount of time.
*
* arguments:
* - message: Text / HTML content to display
*
* options:
* - style: alert style e.g. 'success' / 'warning'
* - timeout: Time (in milliseconds) after which the message will be dismissed
*/
function showMessage(message, options={}) {
var style = options.style || 'info';
var timeout = options.timeout || 5000;
var details = '';
if (options.details) {
details = `<p><small>${options.details}</p></small>`;
}
// Hacky function to get the next available ID
var id = 1;
while ($(`#alert-${id}`).exists()) {
id++;
}
var icon = '';
if (options.icon) {
icon = `<span class='${options.icon}'></span>`;
}
// Construct the alert
var html = `
<div id='alert-${id}' class='alert alert-${style} alert-dismissible fade show' role='alert'>
${icon}
<b>${message}</b>
${details}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
$('#alerts').append(html);
// Remove the alert automatically after a specified period of time
$(`#alert-${id}`).delay(timeout).slideUp(200, function() {
$(this).alert(close); $(this).alert(close);
}); });
} }
function showAlertOrCache(alertType, message, cache, timeout=5000) {
if (cache) {
sessionStorage.setItem("inventree-" + alertType, message);
}
else {
showAlert('#' + alertType, message, timeout);
}
}
function showCachedAlerts() {
// Success Message
if (sessionStorage.getItem("inventree-alert-success")) {
showAlert("#alert-success", sessionStorage.getItem("inventree-alert-success"));
sessionStorage.removeItem("inventree-alert-success");
}
// Info Message
if (sessionStorage.getItem("inventree-alert-info")) {
showAlert("#alert-info", sessionStorage.getItem("inventree-alert-info"));
sessionStorage.removeItem("inventree-alert-info");
}
// Warning Message
if (sessionStorage.getItem("inventree-alert-warning")) {
showAlert("#alert-warning", sessionStorage.getItem("inventree-alert-warning"));
sessionStorage.removeItem("inventree-alert-warning");
}
// Danger Message
if (sessionStorage.getItem("inventree-alert-danger")) {
showAlert("#alert-danger", sessionStorage.getItem("inventree-alert-danger"));
sessionStorage.removeItem("inventree-alert-danger");
}
}

View File

@ -52,7 +52,7 @@ def schedule_task(taskname, **kwargs):
pass pass
def offload_task(taskname, force_sync=False, *args, **kwargs): def offload_task(taskname, *args, force_sync=False, **kwargs):
""" """
Create an AsyncTask if workers are running. Create an AsyncTask if workers are running.
This is different to a 'scheduled' task, This is different to a 'scheduled' task,
@ -108,7 +108,7 @@ def offload_task(taskname, force_sync=False, *args, **kwargs):
return return
# Workers are not running: run it as synchronous task # Workers are not running: run it as synchronous task
_func() _func(*args, **kwargs)
def heartbeat(): def heartbeat():
@ -290,7 +290,7 @@ def update_exchange_rates():
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete() Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
def send_email(subject, body, recipients, from_email=None): def send_email(subject, body, recipients, from_email=None, html_message=None):
""" """
Send an email with the specified subject and body, Send an email with the specified subject and body,
to the specified recipients list. to the specified recipients list.
@ -306,4 +306,5 @@ def send_email(subject, body, recipients, from_email=None):
from_email, from_email,
recipients, recipients,
fail_silently=False, fail_silently=False,
html_message=html_message
) )

View File

@ -102,9 +102,9 @@ class APITests(InvenTreeAPITestCase):
fixtures = [ fixtures = [
'location', 'location',
'stock',
'part',
'category', 'category',
'part',
'stock'
] ]
token = None token = None

View File

@ -42,8 +42,6 @@ from .views import CurrencyRefreshView
from .views import AppearanceSelectView, SettingCategorySelectView from .views import AppearanceSelectView, SettingCategorySelectView
from .views import DynamicJsView from .views import DynamicJsView
from common.views import SettingEdit, UserSettingEdit
from .api import InfoView, NotFoundView from .api import InfoView, NotFoundView
from .api import ActionPluginView from .api import ActionPluginView
@ -53,7 +51,7 @@ admin.site.site_header = "InvenTree Admin"
apipatterns = [ apipatterns = [
url(r'^barcode/', include(barcode_api_urls)), url(r'^barcode/', include(barcode_api_urls)),
url(r'^common/', include(common_api_urls)), url(r'^settings/', include(common_api_urls)),
url(r'^part/', include(part_api_urls)), url(r'^part/', include(part_api_urls)),
url(r'^bom/', include(bom_api_urls)), url(r'^bom/', include(bom_api_urls)),
url(r'^company/', include(company_api_urls)), url(r'^company/', include(company_api_urls)),
@ -85,16 +83,12 @@ settings_urls = [
url(r'^category/', SettingCategorySelectView.as_view(), name='settings-category'), url(r'^category/', SettingCategorySelectView.as_view(), name='settings-category'),
url(r'^(?P<pk>\d+)/edit/user', UserSettingEdit.as_view(), name='user-setting-edit'),
url(r'^(?P<pk>\d+)/edit/', SettingEdit.as_view(), name='setting-edit'),
# Catch any other urls # Catch any other urls
url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'), url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'),
] ]
# These javascript files are served "dynamically" - i.e. rendered on demand # These javascript files are served "dynamically" - i.e. rendered on demand
dynamic_javascript_urls = [ dynamic_javascript_urls = [
url(r'^inventree.js', DynamicJsView.as_view(template_name='js/dynamic/inventree.js'), name='inventree.js'),
url(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'), url(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'),
url(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'), url(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'),
url(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'), url(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'),

View File

@ -12,15 +12,19 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev" INVENTREE_SW_VERSION = "0.6.0 dev"
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 17 INVENTREE_API_VERSION = 18
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v17 -> 2021-10-26 v18 -> 2021-11-11
- Adds support for multiple "Shipments" against a SalesOrder - Adds support for multiple "Shipments" against a SalesOrder
- Refactors process for stock allocation against a SalesOrder - Refactors process for stock allocation against a SalesOrder
v17 -> 2021-11-09
- Adds API endpoints for GLOBAL and USER settings objects
- Ref: https://github.com/inventree/InvenTree/pull/2275
v16 -> 2021-10-17 v16 -> 2021-10-17
- Adds API endpoint for completing build order outputs - Adds API endpoint for completing build order outputs

View File

@ -655,17 +655,6 @@ class IndexView(TemplateView):
context = super(TemplateView, self).get_context_data(**kwargs) context = super(TemplateView, self).get_context_data(**kwargs)
# TODO - Re-implement this when a less expensive method is worked out
# context['starred'] = [star.part for star in self.request.user.starred_parts.all()]
# Generate a list of orderable parts which have stock below their minimum values
# TODO - Is there a less expensive way to get these from the database
# context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()]
# Generate a list of assembly parts which have stock below their minimum values
# TODO - Is there a less expensive way to get these from the database
# context['to_build'] = [part for part in Part.objects.filter(assembly=True) if part.need_to_restock()]
return context return context

View File

@ -9,16 +9,16 @@ import decimal
import os import os
from datetime import datetime from datetime import datetime
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.urls import reverse
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Sum, Q from django.db.models import Sum, Q
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator from django.db.models.signals import post_save
from django.dispatch.dispatcher import receiver
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
@ -27,16 +27,17 @@ from mptt.exceptions import InvalidMove
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
from InvenTree.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
from InvenTree.validators import validate_build_order_reference
import common.models import common.models
import InvenTree.fields import InvenTree.fields
import InvenTree.helpers import InvenTree.helpers
import InvenTree.tasks
from stock import models as StockModels
from part import models as PartModels from part import models as PartModels
from stock import models as StockModels
from users import models as UserModels from users import models as UserModels
@ -46,7 +47,7 @@ def get_next_build_number():
""" """
if Build.objects.count() == 0: if Build.objects.count() == 0:
return return '0001'
build = Build.objects.exclude(reference=None).last() build = Build.objects.exclude(reference=None).last()
@ -106,6 +107,21 @@ class Build(MPTTModel, ReferenceIndexingMixin):
} }
} }
@classmethod
def api_defaults(cls, request):
"""
Return default values for this model when issuing an API OPTIONS request
"""
defaults = {
'reference': get_next_build_number(),
}
if request and request.user:
defaults['issued_by'] = request.user.pk
return defaults
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.rebuild_reference_field() self.rebuild_reference_field()
@ -1014,6 +1030,19 @@ class Build(MPTTModel, ReferenceIndexingMixin):
return self.status == BuildStatus.COMPLETE return self.status == BuildStatus.COMPLETE
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
def after_save_build(sender, instance: Build, created: bool, **kwargs):
"""
Callback function to be executed after a Build instance is saved
"""
if created:
# A new Build has just been created
# Run checks on required parts
InvenTree.tasks.offload_task('build.tasks.check_build_stock', instance)
class BuildOrderAttachment(InvenTreeAttachment): class BuildOrderAttachment(InvenTreeAttachment):
""" """
Model for storing file attachments against a BuildOrder object Model for storing file attachments against a BuildOrder object

96
InvenTree/build/tasks.py Normal file
View File

@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from decimal import Decimal
import logging
from django.utils.translation import ugettext_lazy as _
from django.template.loader import render_to_string
from allauth.account.models import EmailAddress
import build.models
import InvenTree.helpers
import InvenTree.tasks
import part.models as part_models
logger = logging.getLogger('inventree')
def check_build_stock(build: build.models.Build):
"""
Check the required stock for a newly created build order,
and send an email out to any subscribed users if stock is low.
"""
# Iterate through each of the parts required for this build
lines = []
if not build:
logger.error("Invalid build passed to 'build.tasks.check_build_stock'")
return
try:
part = build.part
except part_models.Part.DoesNotExist:
# Note: This error may be thrown during unit testing...
logger.error("Invalid build.part passed to 'build.tasks.check_build_stock'")
return
for bom_item in part.get_bom_items():
sub_part = bom_item.sub_part
# The 'in stock' quantity depends on whether the bom_item allows variants
in_stock = sub_part.get_stock_count(include_variants=bom_item.allow_variants)
allocated = sub_part.allocation_count()
available = max(0, in_stock - allocated)
required = Decimal(bom_item.quantity) * Decimal(build.quantity)
if available < required:
# There is not sufficient stock for this part
lines.append({
'link': InvenTree.helpers.construct_absolute_url(sub_part.get_absolute_url()),
'part': sub_part,
'in_stock': in_stock,
'allocated': allocated,
'available': available,
'required': required,
})
if len(lines) == 0:
# Nothing to do
return
# Are there any users subscribed to these parts?
subscribers = build.part.get_subscribers()
emails = EmailAddress.objects.filter(
user__in=subscribers,
)
if len(emails) > 0:
logger.info(f"Notifying users of stock required for build {build.pk}")
context = {
'link': InvenTree.helpers.construct_absolute_url(build.get_absolute_url()),
'build': build,
'part': build.part,
'lines': lines,
}
# Render the HTML message
html_message = render_to_string('email/build_order_required_stock.html', context)
subject = "[InvenTree] " + _("Stock required for build order")
recipients = emails.values_list('email', flat=True)
InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message)

View File

@ -34,6 +34,7 @@ src="{% static 'img/blank_image.png' %}"
{% include "admin_button.html" with url=url %} {% include "admin_button.html" with url=url %}
{% endif %} {% endif %}
<!-- Printing options --> <!-- Printing options -->
{% if report_enabled %}
<div class='btn-group'> <div class='btn-group'>
<button id='print-options' title='{% trans "Print actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'> <button id='print-options' title='{% trans "Print actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
<span class='fas fa-print'></span> <span class='caret'></span> <span class='fas fa-print'></span> <span class='caret'></span>
@ -42,6 +43,7 @@ src="{% static 'img/blank_image.png' %}"
<li><a class='dropdown-item' href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print build order report" %}</a></li> <li><a class='dropdown-item' href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print build order report" %}</a></li>
</ul> </ul>
</div> </div>
{% endif %}
<!-- Build actions --> <!-- Build actions -->
{% if roles.build.change %} {% if roles.build.change %}
<div class='btn-group'> <div class='btn-group'>
@ -224,9 +226,11 @@ src="{% static 'img/blank_image.png' %}"
{% endif %} {% endif %}
}); });
{% if report_enabled %}
$('#print-build-report').click(function() { $('#print-build-report').click(function() {
printBuildReports([{{ build.pk }}]); printBuildReports([{{ build.pk }}]);
}); });
{% endif %}
$("#build-delete").on('click', function() { $("#build-delete").on('click', function() {
launchModalForm( launchModalForm(

View File

@ -142,7 +142,7 @@
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Completed" %}</td> <td>{% trans "Completed" %}</td>
{% if build.completion_date %} {% if build.completion_date %}
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td> <td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge badge-right rounded-pill bg-dark'>{{ build.completed_by }}</span>{% endif %}</td>
{% else %} {% else %}
<td><em>{% trans "Build not complete" %}</em></td> <td><em>{% trans "Build not complete" %}</em></td>
{% endif %} {% endif %}
@ -160,9 +160,7 @@
<div class='panel-content'> <div class='panel-content'>
<div id='child-button-toolbar'> <div id='child-button-toolbar'>
<div class='button-toolbar container-fluid float-right'> <div class='button-toolbar container-fluid float-right'>
<div class='filter-list' id='filter-list-sub-build'> {% include "filter_list.html" with id='sub-build' %}
<!-- Empty div for filters -->
</div>
</div> </div>
</div> </div>
<table class='table table-striped table-condensed' id='sub-build-table' data-toolbar='#child-button-toolbar'></table> <table class='table table-striped table-condensed' id='sub-build-table' data-toolbar='#child-button-toolbar'></table>
@ -171,7 +169,7 @@
<div class='panel panel-hidden' id='panel-allocate'> <div class='panel panel-hidden' id='panel-allocate'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Allocate Stock to Build" %}</h4> <h4>{% trans "Allocate Stock to Build" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -210,9 +208,7 @@
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'> <button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
<span class='fas fa-sign-in-alt'></span> <span class='fas fa-sign-in-alt'></span>
</button> </button>
<div class='filter-list' id='filter-list-builditems'> {% include "filter_list.html" with id='builditems' %}
<!-- Empty div for table filters-->
</div>
</div> </div>
</div> </div>
</div> </div>
@ -227,7 +223,7 @@
<div class='panel panel-hidden' id='panel-outputs'> <div class='panel panel-hidden' id='panel-outputs'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Incomplete Build Outputs" %}</h4> <h4>{% trans "Incomplete Build Outputs" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -251,7 +247,9 @@
<span class='fas fa-tools'></span> <span class='caret'></span> <span class='fas fa-tools'></span> <span class='caret'></span>
</button> </button>
<ul class='dropdown-menu'> <ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'><span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'>
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
</a></li>
</ul> </ul>
</div> </div>
</div> </div>
@ -276,7 +274,7 @@
<div class='panel panel-hidden' id='panel-attachments'> <div class='panel panel-hidden' id='panel-attachments'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4> <h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>

View File

@ -27,6 +27,7 @@
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if report_enabled %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
<!-- Print actions --> <!-- Print actions -->
<button id='build-print-options' class='btn btn-primary dropdown-toggle' data-bs-toggle='dropdown'> <button id='build-print-options' class='btn btn-primary dropdown-toggle' data-bs-toggle='dropdown'>
@ -38,6 +39,7 @@
</a></li> </a></li>
</ul> </ul>
</div> </div>
{% endif %}
<!-- Buttons to switch between list and calendar views --> <!-- Buttons to switch between list and calendar views -->
<button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'> <button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
<span class='fas fa-calendar-alt'></span> <span class='fas fa-calendar-alt'></span>
@ -45,9 +47,7 @@
<button class='btn btn-outline-secondary' type='button' id='view-list' title='{% trans "Display list view" %}'> <button class='btn btn-outline-secondary' type='button' id='view-list' title='{% trans "Display list view" %}'>
<span class='fas fa-th-list'></span> <span class='fas fa-th-list'></span>
</button> </button>
<div class='filter-list' id='filter-list-build'> {% include "filter_list.html" with id="build" %}
<!-- An empty div in which the filter list will be constructed -->
</div>
</div> </div>
</div> </div>
</div> </div>
@ -183,6 +183,7 @@ loadBuildTable($("#build-table"), {
url: "{% url 'api-build-list' %}", url: "{% url 'api-build-list' %}",
}); });
{% if report_enabled %}
$('#multi-build-print').click(function() { $('#multi-build-print').click(function() {
var rows = $("#build-table").bootstrapTable('getSelections'); var rows = $("#build-table").bootstrapTable('getSelections');
@ -194,5 +195,6 @@ $('#multi-build-print').click(function() {
printBuildReports(build_ids); printBuildReports(build_ids);
}); });
{% endif %}
{% endblock %} {% endblock %}

View File

@ -5,7 +5,7 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from .models import InvenTreeSetting, InvenTreeUserSetting import common.models
class SettingsAdmin(ImportExportModelAdmin): class SettingsAdmin(ImportExportModelAdmin):
@ -18,5 +18,11 @@ class UserSettingsAdmin(ImportExportModelAdmin):
list_display = ('key', 'value', 'user', ) list_display = ('key', 'value', 'user', )
admin.site.register(InvenTreeSetting, SettingsAdmin) class NotificationEntryAdmin(admin.ModelAdmin):
admin.site.register(InvenTreeUserSetting, UserSettingsAdmin)
list_display = ('key', 'uid', 'updated', )
admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)

View File

@ -5,5 +5,149 @@ Provides a JSON API for common components.
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.conf.urls import url, include
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, generics, permissions
import common.models
import common.serializers
class SettingsList(generics.ListAPIView):
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
ordering_fields = [
'pk',
'key',
'name',
]
search_fields = [
'key',
]
class GlobalSettingsList(SettingsList):
"""
API endpoint for accessing a list of global settings objects
"""
queryset = common.models.InvenTreeSetting.objects.all()
serializer_class = common.serializers.GlobalSettingsSerializer
class GlobalSettingsPermissions(permissions.BasePermission):
"""
Special permission class to determine if the user is "staff"
"""
def has_permission(self, request, view):
"""
Check that the requesting user is 'admin'
"""
try:
user = request.user
return user.is_staff
except AttributeError:
return False
class GlobalSettingsDetail(generics.RetrieveUpdateAPIView):
"""
Detail view for an individual "global setting" object.
- User must have 'staff' status to view / edit
"""
queryset = common.models.InvenTreeSetting.objects.all()
serializer_class = common.serializers.GlobalSettingsSerializer
permission_classes = [
GlobalSettingsPermissions,
]
class UserSettingsList(SettingsList):
"""
API endpoint for accessing a list of user settings objects
"""
queryset = common.models.InvenTreeUserSetting.objects.all()
serializer_class = common.serializers.UserSettingsSerializer
def filter_queryset(self, queryset):
"""
Only list settings which apply to the current user
"""
try:
user = self.request.user
except AttributeError:
return common.models.InvenTreeUserSetting.objects.none()
queryset = super().filter_queryset(queryset)
queryset = queryset.filter(user=user)
return queryset
class UserSettingsPermissions(permissions.BasePermission):
"""
Special permission class to determine if the user can view / edit a particular setting
"""
def has_object_permission(self, request, view, obj):
try:
user = request.user
except AttributeError:
return False
return user == obj.user
class UserSettingsDetail(generics.RetrieveUpdateAPIView):
"""
Detail view for an individual "user setting" object
- User can only view / edit settings their own settings objects
"""
queryset = common.models.InvenTreeUserSetting.objects.all()
serializer_class = common.serializers.UserSettingsSerializer
permission_classes = [
UserSettingsPermissions,
]
common_api_urls = [ common_api_urls = [
# User settings
url(r'^user/', include([
# User Settings Detail
url(r'^(?P<pk>\d+)/', UserSettingsDetail.as_view(), name='api-user-setting-detail'),
# User Settings List
url(r'^.*$', UserSettingsList.as_view(), name='api-user-setting-list'),
])),
# Global settings
url(r'^global/', include([
# Global Settings Detail
url(r'^(?P<pk>\d+)/', GlobalSettingsDetail.as_view(), name='api-global-setting-detail'),
# Global Settings List
url(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'),
]))
] ]

View File

@ -0,0 +1,25 @@
# Generated by Django 3.2.5 on 2021-11-03 13:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0011_auto_20210722_2114'),
]
operations = [
migrations.CreateModel(
name='NotificationEntry',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=250)),
('uid', models.IntegerField()),
('updated', models.DateTimeField(auto_now=True)),
],
options={
'unique_together': {('key', 'uid')},
},
),
]

View File

@ -9,6 +9,7 @@ from __future__ import unicode_literals
import os import os
import decimal import decimal
import math import math
from datetime import datetime, timedelta
from django.db import models, transaction from django.db import models, transaction
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
@ -33,6 +34,19 @@ import logging
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
class EmptyURLValidator(URLValidator):
def __call__(self, value):
value = str(value).strip()
if len(value) == 0:
pass
else:
super().__call__(value)
class BaseInvenTreeSetting(models.Model): class BaseInvenTreeSetting(models.Model):
""" """
An base InvenTreeSetting object is a key:value pair used for storing An base InvenTreeSetting object is a key:value pair used for storing
@ -44,6 +58,16 @@ class BaseInvenTreeSetting(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def save(self, *args, **kwargs):
"""
Enforce validation and clean before saving
"""
self.clean()
self.validate_unique()
super().save()
@classmethod @classmethod
def allValues(cls, user=None): def allValues(cls, user=None):
""" """
@ -342,6 +366,11 @@ class BaseInvenTreeSetting(models.Model):
except (ValueError): except (ValueError):
raise ValidationError(_('Must be an integer value')) raise ValidationError(_('Must be an integer value'))
options = self.valid_options()
if options and self.value not in options:
raise ValidationError(_("Chosen value is not a valid option"))
if validator is not None: if validator is not None:
self.run_validator(validator) self.run_validator(validator)
@ -408,6 +437,18 @@ class BaseInvenTreeSetting(models.Model):
return self.__class__.get_setting_choices(self.key) return self.__class__.get_setting_choices(self.key)
def valid_options(self):
"""
Return a list of valid options for this setting
"""
choices = self.choices()
if not choices:
return None
return [opt[0] for opt in choices]
def is_bool(self): def is_bool(self):
""" """
Check if this setting is required to be a boolean value Check if this setting is required to be a boolean value
@ -426,6 +467,20 @@ class BaseInvenTreeSetting(models.Model):
return InvenTree.helpers.str2bool(self.value) return InvenTree.helpers.str2bool(self.value)
def setting_type(self):
"""
Return the field type identifier for this setting object
"""
if self.is_bool():
return 'boolean'
elif self.is_int():
return 'integer'
else:
return 'string'
@classmethod @classmethod
def validator_is_bool(cls, validator): def validator_is_bool(cls, validator):
@ -530,7 +585,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'INVENTREE_BASE_URL': { 'INVENTREE_BASE_URL': {
'name': _('Base URL'), 'name': _('Base URL'),
'description': _('Base URL for server instance'), 'description': _('Base URL for server instance'),
'validator': URLValidator(), 'validator': EmptyURLValidator(),
'default': '', 'default': '',
}, },
@ -713,6 +768,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': InvenTree.validators.validate_part_name_format 'validator': InvenTree.validators.validate_part_name_format
}, },
'REPORT_ENABLE': {
'name': _('Enable Reports'),
'description': _('Enable generation of reports'),
'default': False,
'validator': bool,
},
'REPORT_DEBUG_MODE': { 'REPORT_DEBUG_MODE': {
'name': _('Debug Mode'), 'name': _('Debug Mode'),
'description': _('Generate reports in debug mode (HTML output)'), 'description': _('Generate reports in debug mode (HTML output)'),
@ -807,19 +869,19 @@ class InvenTreeSetting(BaseInvenTreeSetting):
# login / SSO # login / SSO
'LOGIN_ENABLE_PWD_FORGOT': { 'LOGIN_ENABLE_PWD_FORGOT': {
'name': _('Enable password forgot'), 'name': _('Enable password forgot'),
'description': _('Enable password forgot function on the login-pages'), 'description': _('Enable password forgot function on the login pages'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
}, },
'LOGIN_ENABLE_REG': { 'LOGIN_ENABLE_REG': {
'name': _('Enable registration'), 'name': _('Enable registration'),
'description': _('Enable self-registration for users on the login-pages'), 'description': _('Enable self-registration for users on the login pages'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
}, },
'LOGIN_ENABLE_SSO': { 'LOGIN_ENABLE_SSO': {
'name': _('Enable SSO'), 'name': _('Enable SSO'),
'description': _('Enable SSO on the login-pages'), 'description': _('Enable SSO on the login pages'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
}, },
@ -849,7 +911,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'SIGNUP_GROUP': { 'SIGNUP_GROUP': {
'name': _('Group on signup'), 'name': _('Group on signup'),
'description': _('Group new user are asigned on registration'), 'description': _('Group to which new users are assigned on registration'),
'default': '', 'default': '',
'choices': settings_group_options 'choices': settings_group_options
}, },
@ -866,6 +928,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
help_text=_('Settings key (must be unique - case insensitive'), help_text=_('Settings key (must be unique - case insensitive'),
) )
def to_native_value(self):
"""
Return the "pythonic" value,
e.g. convert "True" to True, and "1" to 1
"""
return self.__class__.get_setting(self.key)
class InvenTreeUserSetting(BaseInvenTreeSetting): class InvenTreeUserSetting(BaseInvenTreeSetting):
""" """
@ -874,8 +944,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
GLOBAL_SETTINGS = { GLOBAL_SETTINGS = {
'HOMEPAGE_PART_STARRED': { 'HOMEPAGE_PART_STARRED': {
'name': _('Show starred parts'), 'name': _('Show subscribed parts'),
'description': _('Show starred parts on the homepage'), 'description': _('Show subscribed parts on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_CATEGORY_STARRED': {
'name': _('Show subscribed categories'),
'description': _('Show subscribed part categories on the homepage'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
}, },
@ -1005,6 +1081,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'SEARCH_HIDE_INACTIVE_PARTS': {
'name': _("Hide Inactive Parts"),
'description': _('Hide inactive parts in search preview window'),
'default': False,
'validator': bool,
},
'PART_SHOW_QUANTITY_IN_FORMS': { 'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'), 'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'), 'description': _('Display available part quantity in some forms'),
@ -1063,6 +1146,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'user__id': kwargs['user'].id 'user__id': kwargs['user'].id
} }
def to_native_value(self):
"""
Return the "pythonic" value,
e.g. convert "True" to True, and "1" to 1
"""
return self.__class__.get_setting(self.key, user=self.user)
class PriceBreak(models.Model): class PriceBreak(models.Model):
""" """
@ -1220,3 +1311,63 @@ class ColorTheme(models.Model):
return True return True
return False return False
class NotificationEntry(models.Model):
"""
A NotificationEntry records the last time a particular notifaction was sent out.
It is recorded to ensure that notifications are not sent out "too often" to users.
Attributes:
- key: A text entry describing the notification e.g. 'part.notify_low_stock'
- uid: An (optional) numerical ID for a particular instance
- date: The last time this notification was sent
"""
class Meta:
unique_together = [
('key', 'uid'),
]
key = models.CharField(
max_length=250,
blank=False,
)
uid = models.IntegerField(
)
updated = models.DateTimeField(
auto_now=True,
null=False,
)
@classmethod
def check_recent(cls, key: str, uid: int, delta: timedelta):
"""
Test if a particular notification has been sent in the specified time period
"""
since = datetime.now().date() - delta
entries = cls.objects.filter(
key=key,
uid=uid,
updated__gte=since
)
return entries.exists()
@classmethod
def notify(cls, key: str, uid: int):
"""
Notify the database that a particular notification has been sent out
"""
entry, created = cls.objects.get_or_create(
key=key,
uid=uid
)
entry.save()

View File

@ -1,3 +1,85 @@
""" """
JSON serializers for common components JSON serializers for common components
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from InvenTree.serializers import InvenTreeModelSerializer
from rest_framework import serializers
from common.models import InvenTreeSetting, InvenTreeUserSetting
class SettingsSerializer(InvenTreeModelSerializer):
"""
Base serializer for a settings object
"""
key = serializers.CharField(read_only=True)
name = serializers.CharField(read_only=True)
description = serializers.CharField(read_only=True)
type = serializers.CharField(source='setting_type', read_only=True)
choices = serializers.SerializerMethodField()
def get_choices(self, obj):
"""
Returns the choices available for a given item
"""
results = []
choices = obj.choices()
if choices:
for choice in choices:
results.append({
'value': choice[0],
'display_name': choice[1],
})
return results
class GlobalSettingsSerializer(SettingsSerializer):
"""
Serializer for the InvenTreeSetting model
"""
class Meta:
model = InvenTreeSetting
fields = [
'pk',
'key',
'value',
'name',
'description',
'type',
'choices',
]
class UserSettingsSerializer(SettingsSerializer):
"""
Serializer for the InvenTreeUserSetting model
"""
user = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta:
model = InvenTreeUserSetting
fields = [
'pk',
'key',
'value',
'name',
'description',
'user',
'type',
'choices',
]

29
InvenTree/common/tasks.py Normal file
View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import logging
from datetime import timedelta, datetime
from django.core.exceptions import AppRegistryNotReady
logger = logging.getLogger('inventree')
def delete_old_notifications():
"""
Remove old notifications from the database.
Anything older than ~3 months is removed
"""
try:
from common.models import NotificationEntry
except AppRegistryNotReady:
logger.info("Could not perform 'delete_old_notifications' - App registry not ready")
return
before = datetime.now() - timedelta(days=90)
# Delete notification records before the specified date
NotificationEntry.objects.filter(updated__lte=before).delete()

View File

@ -1,14 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{{ block.super }}
<!--
<p>
<strong>{{ name }}</strong><br>
{{ description }}<br>
<em>{% trans "Current value" %}: {{ value }}</em>
</p>
-->
{% endblock %}

View File

@ -4,156 +4,3 @@ Unit tests for the views associated with the 'common' app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import json
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from common.models import InvenTreeSetting
class SettingsViewTest(TestCase):
"""
Tests for the settings management views
"""
fixtures = [
'settings',
]
def setUp(self):
super().setUp()
# Create a user (required to access the views / forms)
self.user = get_user_model().objects.create_user(
username='username',
email='me@email.com',
password='password',
)
self.client.login(username='username', password='password')
def get_url(self, pk):
return reverse('setting-edit', args=(pk,))
def get_setting(self, title):
return InvenTreeSetting.get_setting_object(title)
def get(self, url, status=200):
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, status)
data = json.loads(response.content)
return response, data
def post(self, url, data, valid=None):
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
json_data = json.loads(response.content)
# If a particular status code is required
if valid is not None:
if valid:
self.assertEqual(json_data['form_valid'], True)
else:
self.assertEqual(json_data['form_valid'], False)
form_errors = json.loads(json_data['form_errors'])
return json_data, form_errors
def test_instance_name(self):
"""
Test that we can get the settings view for particular setting objects.
"""
# Start with something basic - load the settings view for INVENTREE_INSTANCE
setting = self.get_setting('INVENTREE_INSTANCE')
self.assertIsNotNone(setting)
self.assertEqual(setting.value, 'My very first InvenTree Instance')
url = self.get_url(setting.pk)
self.get(url)
new_name = 'A new instance name!'
# Change the instance name via the form
data, errors = self.post(url, {'value': new_name}, valid=True)
name = InvenTreeSetting.get_setting('INVENTREE_INSTANCE')
self.assertEqual(name, new_name)
def test_choices(self):
"""
Tests for a setting which has choices
"""
setting = InvenTreeSetting.get_setting_object('PURCHASEORDER_REFERENCE_PREFIX')
# Default value!
self.assertEqual(setting.value, 'PO')
url = self.get_url(setting.pk)
# Try posting an invalid currency option
data, errors = self.post(url, {'value': 'Purchase Order'}, valid=True)
def test_binary_values(self):
"""
Test for binary value
"""
setting = InvenTreeSetting.get_setting_object('PART_COMPONENT')
self.assertTrue(setting.as_bool())
url = self.get_url(setting.pk)
setting.value = True
setting.save()
# Try posting some invalid values
# The value should be "cleaned" and stay the same
for value in ['', 'abc', 'cat', 'TRUETRUETRUE']:
self.post(url, {'value': value}, valid=True)
# Try posting some valid (True) values
for value in [True, 'True', '1', 'yes']:
self.post(url, {'value': value}, valid=True)
self.assertTrue(InvenTreeSetting.get_setting('PART_COMPONENT'))
# Try posting some valid (False) values
for value in [False, 'False']:
self.post(url, {'value': value}, valid=True)
self.assertFalse(InvenTreeSetting.get_setting('PART_COMPONENT'))
def test_part_name_format(self):
"""
Try posting some valid and invalid name formats for PART_NAME_FORMAT
"""
setting = InvenTreeSetting.get_setting_object('PART_NAME_FORMAT')
# test default value
self.assertEqual(setting.value, "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}"
"{{ ' | ' if part.revision }}{{ part.revision if part.revision }}")
url = self.get_url(setting.pk)
# Try posting an invalid part name format
invalid_values = ['{{asset.IPN}}', '{{part}}', '{{"|"}}', '{{part.falcon}}']
for invalid_value in invalid_values:
self.post(url, {'value': invalid_value}, valid=False)
# try posting valid value
new_format = "{{ part.name if part.name }} {{ ' with revision ' if part.revision }} {{ part.revision }}"
self.post(url, {'value': new_format}, valid=True)

View File

@ -1,10 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from datetime import timedelta
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .models import InvenTreeSetting from .models import InvenTreeSetting
from .models import NotificationEntry
class SettingsTest(TestCase): class SettingsTest(TestCase):
@ -85,3 +88,23 @@ class SettingsTest(TestCase):
if setting.default_value not in [True, False]: if setting.default_value not in [True, False]:
raise ValueError(f'Non-boolean default value specified for {key}') raise ValueError(f'Non-boolean default value specified for {key}')
class NotificationTest(TestCase):
def test_check_notification_entries(self):
# Create some notification entries
self.assertEqual(NotificationEntry.objects.count(), 0)
NotificationEntry.notify('test.notification', 1)
self.assertEqual(NotificationEntry.objects.count(), 1)
delta = timedelta(days=1)
self.assertFalse(NotificationEntry.check_recent('test.notification', 2, delta))
self.assertFalse(NotificationEntry.check_recent('test.notification2', 1, delta))
self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta))

View File

@ -8,138 +8,18 @@ from __future__ import unicode_literals
import os import os
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.forms import CheckboxInput, Select
from django.conf import settings from django.conf import settings
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from formtools.wizard.views import SessionWizardView from formtools.wizard.views import SessionWizardView
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from InvenTree.views import AjaxUpdateView, AjaxView from InvenTree.views import AjaxView
from InvenTree.helpers import str2bool
from . import models
from . import forms from . import forms
from .files import FileManager from .files import FileManager
class SettingEdit(AjaxUpdateView):
"""
View for editing an InvenTree key:value settings object,
(or creating it if the key does not already exist)
"""
model = models.InvenTreeSetting
ajax_form_title = _('Change Setting')
form_class = forms.SettingEditForm
ajax_template_name = "common/edit_setting.html"
def get_context_data(self, **kwargs):
"""
Add extra context information about the particular setting object.
"""
ctx = super().get_context_data(**kwargs)
setting = self.get_object()
ctx['key'] = setting.key
ctx['value'] = setting.value
ctx['name'] = self.model.get_setting_name(setting.key)
ctx['description'] = self.model.get_setting_description(setting.key)
return ctx
def get_data(self):
"""
Custom data to return to the client after POST success
"""
data = {}
setting = self.get_object()
data['pk'] = setting.pk
data['key'] = setting.key
data['value'] = setting.value
data['is_bool'] = setting.is_bool()
data['is_int'] = setting.is_int()
return data
def get_form(self):
"""
Override default get_form behaviour
"""
form = super().get_form()
setting = self.get_object()
choices = setting.choices()
if choices is not None:
form.fields['value'].widget = Select(choices=choices)
elif setting.is_bool():
form.fields['value'].widget = CheckboxInput()
self.object.value = str2bool(setting.value)
form.fields['value'].value = str2bool(setting.value)
name = self.model.get_setting_name(setting.key)
if name:
form.fields['value'].label = name
description = self.model.get_setting_description(setting.key)
if description:
form.fields['value'].help_text = description
return form
def validate(self, setting, form):
"""
Perform custom validation checks on the form data.
"""
data = form.cleaned_data
value = data.get('value', None)
if setting.choices():
"""
If a set of choices are provided for a given setting,
the provided value must be one of those choices.
"""
choices = [choice[0] for choice in setting.choices()]
if value not in choices:
form.add_error('value', _('Supplied value is not allowed'))
if setting.is_bool():
"""
If a setting is defined as a boolean setting,
the provided value must look somewhat like a boolean value!
"""
if not str2bool(value, test=True) and not str2bool(value, test=False):
form.add_error('value', _('Supplied value must be a boolean'))
class UserSettingEdit(SettingEdit):
"""
View for editing an InvenTree key:value user settings object,
(or creating it if the key does not already exist)
"""
model = models.InvenTreeUserSetting
ajax_form_title = _('Change User Setting')
form_class = forms.SettingEditForm
ajax_template_name = "common/edit_setting.html"
class MultiStepFormView(SessionWizardView): class MultiStepFormView(SessionWizardView):
""" Setup basic methods of multi-step form """ Setup basic methods of multi-step form

View File

@ -11,7 +11,7 @@
<div class='panel panel-hidden' id='panel-supplier-parts'> <div class='panel panel-hidden' id='panel-supplier-parts'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Supplier Parts" %}</h4> <h4>{% trans "Supplier Parts" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -46,9 +46,7 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class='filter-list' id='filter-list-supplier-part'> {% include "filter_list.html" with id="supplier-part" %}
<!-- Empty div (will be filled out with available BOM filters) -->
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -60,7 +58,7 @@
<div class='panel panel-hidden' id='panel-manufacturer-parts'> <div class='panel panel-hidden' id='panel-manufacturer-parts'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Manufacturer Parts" %}</h4> <h4>{% trans "Manufacturer Parts" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -95,9 +93,7 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class='filter-list' id='filter-list-supplier-part'> {% include "filter_list.html" with id="supplier-part" %}
<!-- Empty div (will be filled out with available BOM filters) -->
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -117,7 +113,7 @@
<div class='panel panel-hidden' id='panel-purchase-orders'> <div class='panel panel-hidden' id='panel-purchase-orders'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Purchase Orders" %}</h4> <h4>{% trans "Purchase Orders" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -132,9 +128,7 @@
<div class='panel-content'> <div class='panel-content'>
<div id='po-button-bar'> <div id='po-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='filter-list' id='filter-list-purchaseorder'> {% include "filter_list.html" with id="purchaseorder" %}
<!-- Empty div -->
</div>
</div> </div>
</div> </div>
@ -145,7 +139,7 @@
<div class='panel panel-hidden' id='panel-sales-orders'> <div class='panel panel-hidden' id='panel-sales-orders'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Sales Orders" %}</h4> <h4>{% trans "Sales Orders" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -160,9 +154,7 @@
<div class='panel-content'> <div class='panel-content'>
<div id='so-button-bar'> <div id='so-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='filter-list' id='filter-list-salesorder'> {% include "filter_list.html" with id="salesorder" %}
<!-- Empty div -->
</div>
</div> </div>
</div> </div>
@ -177,9 +169,7 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='assigned-stock-button-toolbar'> <div id='assigned-stock-button-toolbar'>
<div class='filter-list' id='filter-list-stock'> {% include "filter_list.html" with id="stock" %}
<!-- An empty div in which the filter list will be constructed -->
</div>
</div> </div>
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#bassigned-stock-utton-toolbar'></table> <table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#bassigned-stock-utton-toolbar'></table>

View File

@ -104,7 +104,7 @@ src="{% static 'img/blank_image.png' %}"
<div class='panel panel-hidden' id='panel-supplier-parts'> <div class='panel panel-hidden' id='panel-supplier-parts'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Suppliers" %}</h4> <h4>{% trans "Suppliers" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -133,7 +133,7 @@ src="{% static 'img/blank_image.png' %}"
<div class='panel panel-hidden' id='panel-parameters'> <div class='panel panel-hidden' id='panel-parameters'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Parameters" %}</h4> <h4>{% trans "Parameters" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>

View File

@ -134,7 +134,15 @@ src="{% static 'img/blank_image.png' %}"
<div class='panel panel-hidden' id='panel-stock'> <div class='panel panel-hidden' id='panel-stock'>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Supplier Part Stock" %}</h4> <span class='d-flex flex-wrap'>
<h4>{% trans "Supplier Part Stock" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='item-create' title='{% trans "Create new stock item" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
</button>
</div>
</span>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% include "stock_table.html" %} {% include "stock_table.html" %}
@ -143,7 +151,7 @@ src="{% static 'img/blank_image.png' %}"
<div class='panel panel-hidden' id='panel-purchase-orders'> <div class='panel panel-hidden' id='panel-purchase-orders'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Supplier Part Orders" %}</h4> <h4>{% trans "Supplier Part Orders" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -167,7 +175,7 @@ src="{% static 'img/blank_image.png' %}"
<div class='panel panel-hidden' id='panel-pricing'> <div class='panel panel-hidden' id='panel-pricing'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Pricing Information" %}</h4> <h4>{% trans "Pricing Information" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -314,7 +322,6 @@ $("#item-create").click(function() {
part: {{ part.part.id }}, part: {{ part.part.id }},
supplier_part: {{ part.id }}, supplier_part: {{ part.id }},
}, },
reload: true,
}); });
}); });

View File

@ -83,7 +83,9 @@ class POLineItemResource(ModelResource):
class SOLineItemResource(ModelResource): class SOLineItemResource(ModelResource):
""" Class for managing import / export of SOLineItem data """ """
Class for managing import / export of SOLineItem data
"""
part_name = Field(attribute='part__name', readonly=True) part_name = Field(attribute='part__name', readonly=True)
@ -93,6 +95,17 @@ class SOLineItemResource(ModelResource):
fulfilled = Field(attribute='fulfilled_quantity', readonly=True) fulfilled = Field(attribute='fulfilled_quantity', readonly=True)
def dehydrate_sale_price(self, item):
"""
Return a string value of the 'sale_price' field, rather than the 'Money' object.
Ref: https://github.com/inventree/InvenTree/issues/2207
"""
if item.sale_price:
return str(item.sale_price)
else:
return ''
class Meta: class Meta:
model = SalesOrderLineItem model = SalesOrderLineItem
skip_unchanged = True skip_unchanged = True

View File

@ -37,7 +37,7 @@ def get_next_po_number():
""" """
if PurchaseOrder.objects.count() == 0: if PurchaseOrder.objects.count() == 0:
return "001" return '0001'
order = PurchaseOrder.objects.exclude(reference=None).last() order = PurchaseOrder.objects.exclude(reference=None).last()
@ -66,7 +66,7 @@ def get_next_so_number():
""" """
if SalesOrder.objects.count() == 0: if SalesOrder.objects.count() == 0:
return "001" return '0001'
order = SalesOrder.objects.exclude(reference=None).last() order = SalesOrder.objects.exclude(reference=None).last()

View File

@ -241,6 +241,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
help_text=_('Unique identifier field'), help_text=_('Unique identifier field'),
default='', default='',
required=False, required=False,
allow_null=True,
allow_blank=True, allow_blank=True,
) )

View File

@ -29,7 +29,9 @@
<span class='fas fa-print'></span> <span class='caret'></span> <span class='fas fa-print'></span> <span class='caret'></span>
</button> </button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print purchase order report" %}</a></li> <li><a class='dropdown-item' href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print purchase order report" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li> <li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
</ul> </ul>
</div> </div>
@ -123,7 +125,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td> <td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge'>{{ order.created_by }}</span></td> <td>{{ order.creation_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td>
</tr> </tr>
{% if order.issue_date %} {% if order.issue_date %}
<tr> <tr>
@ -143,7 +145,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td> <td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td> <td>{{ order.complete_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.responsible %} {% if order.responsible %}
@ -169,9 +171,11 @@ $("#place-order").click(function() {
}); });
{% endif %} {% endif %}
{% if report_enabled %}
$('#print-order-report').click(function() { $('#print-order-report').click(function() {
printPurchaseOrderReports([{{ order.pk }}]); printPurchaseOrderReports([{{ order.pk }}]);
}); });
{% endif %}
$("#edit-order").click(function() { $("#edit-order").click(function() {

View File

@ -14,7 +14,7 @@
<div class='panel panel-hidden' id='panel-order-items'> <div class='panel panel-hidden' id='panel-order-items'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Purchase Order Items" %}</h4> <h4>{% trans "Purchase Order Items" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -37,9 +37,7 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'> <div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
<div class='filter-list' id='filter-list-purchase-order-lines'> {% include "filter_list.html" with id="order-lines" %}
<!-- An empty div in which the filter list will be constructed-->
</div>
</div> </div>
<table class='table table-striped table-condensed' id='po-line-table' data-toolbar='#order-toolbar-buttons'> <table class='table table-striped table-condensed' id='po-line-table' data-toolbar='#order-toolbar-buttons'>
@ -52,13 +50,13 @@
<h4>{% trans "Received Items" %}</h4> <h4>{% trans "Received Items" %}</h4>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% include "stock_table.html" with prevent_new_stock=True %} {% include "stock_table.html" %}
</div> </div>
</div> </div>
<div class='panel panel-hidden' id='panel-order-attachments'> <div class='panel panel-hidden' id='panel-order-attachments'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4> <h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>

View File

@ -26,19 +26,18 @@
<div id='table-buttons'> <div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if report_enabled %}
<button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'> <button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'>
<span class='fas fa-print'></span> <span class='fas fa-print'></span>
</button> </button>
{% endif %}
<button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'> <button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
<span class='fas fa-calendar-alt'></span> <span class='fas fa-calendar-alt'></span>
</button> </button>
<button class='btn btn-outline-secondary' type='button' id='view-list' title='{% trans "Display list view" %}'> <button class='btn btn-outline-secondary' type='button' id='view-list' title='{% trans "Display list view" %}'>
<span class='fas fa-th-list'></span> <span class='fas fa-th-list'></span>
</button> </button>
<div class='filter-list' id='filter-list-purchaseorder'> {% include "filter_list.html" with id="purchaseorder" %}
<!-- An empty div in which the filter list will be constructed -->
</div>
</div> </div>
</div> </div>
</div> </div>
@ -171,6 +170,7 @@ $("#view-list").click(function() {
$("#view-calendar").show(); $("#view-calendar").show();
}); });
{% if report_enabled %}
$("#order-print").click(function() { $("#order-print").click(function() {
var rows = $("#purchase-order-table").bootstrapTable('getSelections'); var rows = $("#purchase-order-table").bootstrapTable('getSelections');
@ -182,6 +182,7 @@ $("#order-print").click(function() {
printPurchaseOrderReports(orders); printPurchaseOrderReports(orders);
}) })
{% endif %}
$("#po-create").click(function() { $("#po-create").click(function() {
createPurchaseOrder(); createPurchaseOrder();

View File

@ -16,7 +16,7 @@
{% block thumbnail %} {% block thumbnail %}
<img class='part-thumb' <img class='part-thumb'
{% if order.customer.image %} {% if order.customer and order.customer.image %}
src="{{ order.customer.image.url }}" src="{{ order.customer.image.url }}"
{% else %} {% else %}
src="{% static 'img/blank_image.png' %}" src="{% static 'img/blank_image.png' %}"
@ -39,7 +39,9 @@ src="{% static 'img/blank_image.png' %}"
<span class='fas fa-print'></span> <span class='caret'></span> <span class='fas fa-print'></span> <span class='caret'></span>
</button> </button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print sales order report" %}</a></li> <li><a class='dropdown-item' href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print sales order report" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li> <li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
<!-- <!--
<li><a class='dropdown-item' href='#' id='print-packing-list'><span class='fas fa-clipboard-list'></span>{% trans "Print packing list" %}</a></li> <li><a class='dropdown-item' href='#' id='print-packing-list'><span class='fas fa-clipboard-list'></span>{% trans "Print packing list" %}</a></li>
@ -106,11 +108,13 @@ src="{% static 'img/blank_image.png' %}"
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% if order.customer %}
<tr> <tr>
<td><span class='fas fa-building'></span></td> <td><span class='fas fa-building'></span></td>
<td>{% trans "Customer" %}</td> <td>{% trans "Customer" %}</td>
<td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a>{% include "clip.html"%}</td> <td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %}
{% if order.customer_reference %} {% if order.customer_reference %}
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
@ -128,7 +132,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td> <td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge'>{{ order.created_by }}</span></td> <td>{{ order.creation_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td>
</tr> </tr>
{% if order.target_date %} {% if order.target_date %}
<tr> <tr>
@ -141,14 +145,14 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-truck'></span></td> <td><span class='fas fa-truck'></span></td>
<td>{% trans "Shipped" %}</td> <td>{% trans "Shipped" %}</td>
<td>{{ order.shipment_date }}<span class='badge'>{{ order.shipped_by }}</span></td> <td>{{ order.shipment_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.shipped_by }}</span></td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %} {% if order.status == PurchaseOrderStatus.COMPLETE %}
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td> <td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td> <td>{{ order.complete_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.responsible %} {% if order.responsible %}
@ -204,9 +208,11 @@ $("#ship-order").click(function() {
}); });
}); });
{% if report_enabled %}
$('#print-order-report').click(function() { $('#print-order-report').click(function() {
printSalesOrderReports([{{ order.pk }}]); printSalesOrderReports([{{ order.pk }}]);
}); });
{% endif %}
$('#export-order').click(function() { $('#export-order').click(function() {
exportOrder('{% url "so-export" order.id %}'); exportOrder('{% url "so-export" order.id %}');

View File

@ -14,7 +14,7 @@
<div class='panel panel-hidden' id='panel-order-items'> <div class='panel panel-hidden' id='panel-order-items'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Sales Order Items" %}</h4> <h4>{% trans "Sales Order Items" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -29,8 +29,7 @@
<div class='panel-content'> <div class='panel-content'>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'> <div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
<div class='btn-group'> <div class='btn-group'>
<div class='filter-list' id='filter-list-sales-order-lines'> {% include "filter_list.html" with id="sales-order-lines" %}
</div>
</div> </div>
</div> </div>
<table class='table table-striped table-condensed' id='so-lines-table' data-toolbar='#order-toolbar-buttons'> <table class='table table-striped table-condensed' id='so-lines-table' data-toolbar='#order-toolbar-buttons'>
@ -78,7 +77,7 @@
<div class='panel panel-hidden' id='panel-order-attachments'> <div class='panel panel-hidden' id='panel-order-attachments'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4> <h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>

View File

@ -29,19 +29,18 @@
<div id='table-buttons'> <div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'> <div class='btn-group'>
{% if report_enabled %}
<button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'> <button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'>
<span class='fas fa-print'></span> <span class='fas fa-print'></span>
</button> </button>
{% endif %}
<button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'> <button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
<span class='fas fa-calendar-alt'></span> <span class='fas fa-calendar-alt'></span>
</button> </button>
<button class='btn btn-outline-secondary' type='button' id='view-list' title='{% trans "Display list view" %}'> <button class='btn btn-outline-secondary' type='button' id='view-list' title='{% trans "Display list view" %}'>
<span class='fas fa-th-list'></span> <span class='fas fa-th-list'></span>
</button> </button>
<div class='filter-list' id='filter-list-salesorder'> {% include "filter_list.html" with id="salesorder" %}
<!-- An empty div in which the filter list will be constructed -->
</div>
</div> </div>
</div> </div>
</div> </div>
@ -175,6 +174,7 @@ loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}", url: "{% url 'api-so-list' %}",
}); });
{% if report_enabled %}
$("#order-print").click(function() { $("#order-print").click(function() {
var rows = $("#sales-order-table").bootstrapTable('getSelections'); var rows = $("#sales-order-table").bootstrapTable('getSelections');
@ -186,6 +186,7 @@ $("#order-print").click(function() {
printSalesOrderReports(orders); printSalesOrderReports(orders);
}) })
{% endif %}
$("#so-create").click(function() { $("#so-create").click(function() {
createSalesOrder(); createSalesOrder();

View File

@ -350,6 +350,31 @@ class PurchaseOrderReceiveTest(OrderTest):
# No new stock items have been created # No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count()) self.assertEqual(self.n, StockItem.objects.count())
def test_null_barcode(self):
"""
Test than a "null" barcode field can be provided
"""
# Set stock item barcode
item = StockItem.objects.get(pk=1)
item.save()
# Test with "null" value
self.post(
self.url,
{
'items': [
{
'line_item': 1,
'quantity': 50,
'barcode': None,
}
],
'location': 1,
},
expected_code=201
)
def test_invalid_barcodes(self): def test_invalid_barcodes(self):
""" """
Tests for checking in items with invalid barcodes: Tests for checking in items with invalid barcodes:

View File

@ -8,13 +8,7 @@ from import_export.resources import ModelResource
from import_export.fields import Field from import_export.fields import Field
import import_export.widgets as widgets import import_export.widgets as widgets
from .models import PartCategory, Part import part.models as models
from .models import PartAttachment, PartStar, PartRelated
from .models import BomItem
from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate
from .models import PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
from stock.models import StockLocation from stock.models import StockLocation
from company.models import SupplierPart from company.models import SupplierPart
@ -24,7 +18,7 @@ class PartResource(ModelResource):
""" Class for managing Part data import/export """ """ Class for managing Part data import/export """
# ForeignKey fields # ForeignKey fields
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(PartCategory)) category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory))
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation)) default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
@ -32,7 +26,7 @@ class PartResource(ModelResource):
category_name = Field(attribute='category__name', readonly=True) category_name = Field(attribute='category__name', readonly=True)
variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(Part)) variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(models.Part))
suppliers = Field(attribute='supplier_count', readonly=True) suppliers = Field(attribute='supplier_count', readonly=True)
@ -48,7 +42,7 @@ class PartResource(ModelResource):
building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget()) building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget())
class Meta: class Meta:
model = Part model = models.Part
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instances = True clean_model_instances = True
@ -86,14 +80,14 @@ class PartAdmin(ImportExportModelAdmin):
class PartCategoryResource(ModelResource): class PartCategoryResource(ModelResource):
""" Class for managing PartCategory data import/export """ """ Class for managing PartCategory data import/export """
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(PartCategory)) parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
parent_name = Field(attribute='parent__name', readonly=True) parent_name = Field(attribute='parent__name', readonly=True)
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation)) default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
class Meta: class Meta:
model = PartCategory model = models.PartCategory
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instances = True clean_model_instances = True
@ -108,14 +102,14 @@ class PartCategoryResource(ModelResource):
super().after_import(dataset, result, using_transactions, dry_run, **kwargs) super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
# Rebuild the PartCategory tree(s) # Rebuild the PartCategory tree(s)
PartCategory.objects.rebuild() models.PartCategory.objects.rebuild()
class PartCategoryInline(admin.TabularInline): class PartCategoryInline(admin.TabularInline):
""" """
Inline for PartCategory model Inline for PartCategory model
""" """
model = PartCategory model = models.PartCategory
class PartCategoryAdmin(ImportExportModelAdmin): class PartCategoryAdmin(ImportExportModelAdmin):
@ -146,6 +140,11 @@ class PartStarAdmin(admin.ModelAdmin):
list_display = ('part', 'user') list_display = ('part', 'user')
class PartCategoryStarAdmin(admin.ModelAdmin):
list_display = ('category', 'user')
class PartTestTemplateAdmin(admin.ModelAdmin): class PartTestTemplateAdmin(admin.ModelAdmin):
list_display = ('part', 'test_name', 'required') list_display = ('part', 'test_name', 'required')
@ -159,7 +158,7 @@ class BomItemResource(ModelResource):
bom_id = Field(attribute='pk') bom_id = Field(attribute='pk')
# ID of the parent part # ID of the parent part
parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
# IPN of the parent part # IPN of the parent part
parent_part_ipn = Field(attribute='part__IPN', readonly=True) parent_part_ipn = Field(attribute='part__IPN', readonly=True)
@ -168,7 +167,7 @@ class BomItemResource(ModelResource):
parent_part_name = Field(attribute='part__name', readonly=True) parent_part_name = Field(attribute='part__name', readonly=True)
# ID of the sub-part # ID of the sub-part
part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(Part)) part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(models.Part))
# IPN of the sub-part # IPN of the sub-part
part_ipn = Field(attribute='sub_part__IPN', readonly=True) part_ipn = Field(attribute='sub_part__IPN', readonly=True)
@ -233,7 +232,7 @@ class BomItemResource(ModelResource):
return fields return fields
class Meta: class Meta:
model = BomItem model = models.BomItem
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instances = True clean_model_instances = True
@ -262,16 +261,16 @@ class ParameterTemplateAdmin(ImportExportModelAdmin):
class ParameterResource(ModelResource): class ParameterResource(ModelResource):
""" Class for managing PartParameter data import/export """ """ Class for managing PartParameter data import/export """
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
part_name = Field(attribute='part__name', readonly=True) part_name = Field(attribute='part__name', readonly=True)
template = Field(attribute='template', widget=widgets.ForeignKeyWidget(PartParameterTemplate)) template = Field(attribute='template', widget=widgets.ForeignKeyWidget(models.PartParameterTemplate))
template_name = Field(attribute='template__name', readonly=True) template_name = Field(attribute='template__name', readonly=True)
class Meta: class Meta:
model = PartParameter model = models.PartParameter
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instance = True clean_model_instance = True
@ -292,7 +291,7 @@ class PartCategoryParameterAdmin(admin.ModelAdmin):
class PartSellPriceBreakAdmin(admin.ModelAdmin): class PartSellPriceBreakAdmin(admin.ModelAdmin):
class Meta: class Meta:
model = PartSellPriceBreak model = models.PartSellPriceBreak
list_display = ('part', 'quantity', 'price',) list_display = ('part', 'quantity', 'price',)
@ -300,20 +299,21 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
class PartInternalPriceBreakAdmin(admin.ModelAdmin): class PartInternalPriceBreakAdmin(admin.ModelAdmin):
class Meta: class Meta:
model = PartInternalPriceBreak model = models.PartInternalPriceBreak
list_display = ('part', 'quantity', 'price',) list_display = ('part', 'quantity', 'price',)
admin.site.register(Part, PartAdmin) admin.site.register(models.Part, PartAdmin)
admin.site.register(PartCategory, PartCategoryAdmin) admin.site.register(models.PartCategory, PartCategoryAdmin)
admin.site.register(PartRelated, PartRelatedAdmin) admin.site.register(models.PartRelated, PartRelatedAdmin)
admin.site.register(PartAttachment, PartAttachmentAdmin) admin.site.register(models.PartAttachment, PartAttachmentAdmin)
admin.site.register(PartStar, PartStarAdmin) admin.site.register(models.PartStar, PartStarAdmin)
admin.site.register(BomItem, BomItemAdmin) admin.site.register(models.PartCategoryStar, PartCategoryStarAdmin)
admin.site.register(PartParameterTemplate, ParameterTemplateAdmin) admin.site.register(models.BomItem, BomItemAdmin)
admin.site.register(PartParameter, ParameterAdmin) admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin) admin.site.register(models.PartParameter, ParameterAdmin)
admin.site.register(PartTestTemplate, PartTestTemplateAdmin) admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterAdmin)
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin) admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin) admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)

View File

@ -58,6 +58,18 @@ class CategoryList(generics.ListCreateAPIView):
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer serializer_class = part_serializers.CategorySerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
try:
ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
except AttributeError:
# Error is thrown if the view does not have an associated request
ctx['starred_categories'] = []
return ctx
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
""" """
Custom filtering: Custom filtering:
@ -110,6 +122,18 @@ class CategoryList(generics.ListCreateAPIView):
except (ValueError, PartCategory.DoesNotExist): except (ValueError, PartCategory.DoesNotExist):
pass pass
# Filter by "starred" status
starred = params.get('starred', None)
if starred is not None:
starred = str2bool(starred)
starred_categories = [star.category.pk for star in self.request.user.starred_categories.all()]
if starred:
queryset = queryset.filter(pk__in=starred_categories)
else:
queryset = queryset.exclude(pk__in=starred_categories)
return queryset return queryset
filter_backends = [ filter_backends = [
@ -149,6 +173,29 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = part_serializers.CategorySerializer serializer_class = part_serializers.CategorySerializer
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
try:
ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
except AttributeError:
# Error is thrown if the view does not have an associated request
ctx['starred_categories'] = []
return ctx
def update(self, request, *args, **kwargs):
if 'starred' in request.data:
starred = str2bool(request.data.get('starred', False))
self.get_object().set_starred(request.user, starred)
response = super().update(request, *args, **kwargs)
return response
class CategoryParameterList(generics.ListAPIView): class CategoryParameterList(generics.ListAPIView):
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects. """ API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@ -389,7 +436,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
# Ensure the request context is passed through # Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context() kwargs['context'] = self.get_serializer_context()
# Pass a list of "starred" parts fo the current user to the serializer # Pass a list of "starred" parts of the current user to the serializer
# We do this to reduce the number of database queries required! # We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None: if self.starred_parts is None and self.request is not None:
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()] self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
@ -418,9 +465,9 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
""" """
if 'starred' in request.data: if 'starred' in request.data:
starred = str2bool(request.data.get('starred', None)) starred = str2bool(request.data.get('starred', False))
self.get_object().setStarred(request.user, starred) self.get_object().set_starred(request.user, starred)
response = super().update(request, *args, **kwargs) response = super().update(request, *args, **kwargs)

View File

@ -7,7 +7,7 @@ from collections import OrderedDict
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from InvenTree.helpers import DownloadFile, GetExportFormats from InvenTree.helpers import DownloadFile, GetExportFormats, normalize
from .admin import BomItemResource from .admin import BomItemResource
from .models import BomItem from .models import BomItem
@ -59,7 +59,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
uids = [] uids = []
def add_items(items, level): def add_items(items, level, cascade):
# Add items at a given layer # Add items at a given layer
for item in items: for item in items:
@ -71,21 +71,13 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
bom_items.append(item) bom_items.append(item)
if item.sub_part.assembly: if cascade and item.sub_part.assembly:
if max_levels is None or level < max_levels: if max_levels is None or level < max_levels:
add_items(item.sub_part.bom_items.all().order_by('id'), level + 1) add_items(item.sub_part.bom_items.all().order_by('id'), level + 1)
if cascade: top_level_items = part.get_bom_items().order_by('id')
# Cascading (multi-level) BOM
# Start with the top level add_items(top_level_items, 1, cascade)
items_to_process = part.bom_items.all().order_by('id')
add_items(items_to_process, 1)
else:
# No cascading needed - just the top-level items
bom_items = [item for item in part.bom_items.all().order_by('id')]
dataset = BomItemResource().export(queryset=bom_items, cascade=cascade) dataset = BomItemResource().export(queryset=bom_items, cascade=cascade)
@ -148,8 +140,9 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
stock_data.append('') stock_data.append('')
except AttributeError: except AttributeError:
stock_data.append('') stock_data.append('')
# Get part current stock # Get part current stock
stock_data.append(str(bom_item.sub_part.available_stock)) stock_data.append(str(normalize(bom_item.sub_part.available_stock)))
for s_idx, header in enumerate(stock_headers): for s_idx, header in enumerate(stock_headers):
try: try:
@ -160,171 +153,108 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# Add stock columns to dataset # Add stock columns to dataset
add_columns_to_dataset(stock_cols, len(bom_items)) add_columns_to_dataset(stock_cols, len(bom_items))
if manufacturer_data and supplier_data: if manufacturer_data or supplier_data:
""" """
If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item
""" """
# Expand dataset with manufacturer parts # Keep track of the supplier parts we have already exported
manufacturer_headers = [ supplier_parts_used = set()
_('Manufacturer'),
_('MPN'),
]
supplier_headers = [
_('Supplier'),
_('SKU'),
]
manufacturer_cols = {} manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items): for bom_idx, bom_item in enumerate(bom_items):
# Get part instance # Get part instance
b_part = bom_item.sub_part b_part = bom_item.sub_part
# Filter manufacturer parts # Include manufacturer data for each BOM item
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk) if manufacturer_data:
manufacturer_parts = manufacturer_parts.prefetch_related('supplier_parts')
# Process manufacturer part # Filter manufacturer parts
for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts): manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk).prefetch_related('supplier_parts')
if manufacturer_part and manufacturer_part.manufacturer: for mp_idx, mp_part in enumerate(manufacturer_parts):
manufacturer_name = manufacturer_part.manufacturer.name
else:
manufacturer_name = ''
if manufacturer_part: # Extract the "name" field of the Manufacturer (Company)
manufacturer_mpn = manufacturer_part.MPN if mp_part and mp_part.manufacturer:
else: manufacturer_name = mp_part.manufacturer.name
manufacturer_mpn = '' else:
manufacturer_name = ''
# Generate column names for this manufacturer # Extract the "MPN" field from the Manufacturer Part
k_man = manufacturer_headers[0] + "_" + str(manufacturer_idx) if mp_part:
k_mpn = manufacturer_headers[1] + "_" + str(manufacturer_idx) manufacturer_mpn = mp_part.MPN
else:
manufacturer_mpn = ''
try: # Generate a column name for this manufacturer
manufacturer_cols[k_man].update({b_idx: manufacturer_name}) k_man = f'{_("Manufacturer")}_{mp_idx}'
manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn}) k_mpn = f'{_("MPN")}_{mp_idx}'
except KeyError:
manufacturer_cols[k_man] = {b_idx: manufacturer_name}
manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn}
# Process supplier parts try:
for supplier_idx, supplier_part in enumerate(manufacturer_part.supplier_parts.all()): manufacturer_cols[k_man].update({bom_idx: manufacturer_name})
manufacturer_cols[k_mpn].update({bom_idx: manufacturer_mpn})
except KeyError:
manufacturer_cols[k_man] = {bom_idx: manufacturer_name}
manufacturer_cols[k_mpn] = {bom_idx: manufacturer_mpn}
if supplier_part.supplier and supplier_part.supplier: # We wish to include supplier data for this manufacturer part
supplier_name = supplier_part.supplier.name if supplier_data:
for sp_idx, sp_part in enumerate(mp_part.supplier_parts.all()):
supplier_parts_used.add(sp_part)
if sp_part.supplier and sp_part.supplier:
supplier_name = sp_part.supplier.name
else:
supplier_name = ''
if sp_part:
supplier_sku = sp_part.SKU
else:
supplier_sku = ''
# Generate column names for this supplier
k_sup = str(_("Supplier")) + "_" + str(mp_idx) + "_" + str(sp_idx)
k_sku = str(_("SKU")) + "_" + str(mp_idx) + "_" + str(sp_idx)
try:
manufacturer_cols[k_sup].update({bom_idx: supplier_name})
manufacturer_cols[k_sku].update({bom_idx: supplier_sku})
except KeyError:
manufacturer_cols[k_sup] = {bom_idx: supplier_name}
manufacturer_cols[k_sku] = {bom_idx: supplier_sku}
if supplier_data:
# Add in any extra supplier parts, which are not associated with a manufacturer part
for sp_idx, sp_part in enumerate(SupplierPart.objects.filter(part__pk=b_part.pk)):
if sp_part in supplier_parts_used:
continue
supplier_parts_used.add(sp_part)
if sp_part.supplier:
supplier_name = sp_part.supplier.name
else: else:
supplier_name = '' supplier_name = ''
if supplier_part: supplier_sku = sp_part.SKU
supplier_sku = supplier_part.SKU
else:
supplier_sku = ''
# Generate column names for this supplier # Generate column names for this supplier
k_sup = str(supplier_headers[0]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx) k_sup = str(_("Supplier")) + "_" + str(sp_idx)
k_sku = str(supplier_headers[1]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx) k_sku = str(_("SKU")) + "_" + str(sp_idx)
try: try:
manufacturer_cols[k_sup].update({b_idx: supplier_name}) manufacturer_cols[k_sup].update({bom_idx: supplier_name})
manufacturer_cols[k_sku].update({b_idx: supplier_sku}) manufacturer_cols[k_sku].update({bom_idx: supplier_sku})
except KeyError: except KeyError:
manufacturer_cols[k_sup] = {b_idx: supplier_name} manufacturer_cols[k_sup] = {bom_idx: supplier_name}
manufacturer_cols[k_sku] = {b_idx: supplier_sku} manufacturer_cols[k_sku] = {bom_idx: supplier_sku}
# Add manufacturer columns to dataset # Add supplier columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items))
elif manufacturer_data:
"""
If requested, add extra columns for each ManufacturerPart associated with each line item
"""
# Expand dataset with manufacturer parts
manufacturer_headers = [
_('Manufacturer'),
_('MPN'),
]
manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items):
# Get part instance
b_part = bom_item.sub_part
# Filter supplier parts
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk)
for idx, manufacturer_part in enumerate(manufacturer_parts):
if manufacturer_part:
manufacturer_name = manufacturer_part.manufacturer.name
else:
manufacturer_name = ''
manufacturer_mpn = manufacturer_part.MPN
# Add manufacturer data to the manufacturer columns
# Generate column names for this manufacturer
k_man = manufacturer_headers[0] + "_" + str(idx)
k_mpn = manufacturer_headers[1] + "_" + str(idx)
try:
manufacturer_cols[k_man].update({b_idx: manufacturer_name})
manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn})
except KeyError:
manufacturer_cols[k_man] = {b_idx: manufacturer_name}
manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn}
# Add manufacturer columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items))
elif supplier_data:
"""
If requested, add extra columns for each SupplierPart associated with each line item
"""
# Expand dataset with manufacturer parts
manufacturer_headers = [
_('Supplier'),
_('SKU'),
]
manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items):
# Get part instance
b_part = bom_item.sub_part
# Filter supplier parts
supplier_parts = SupplierPart.objects.filter(part__pk=b_part.pk)
for idx, supplier_part in enumerate(supplier_parts):
if supplier_part.supplier:
supplier_name = supplier_part.supplier.name
else:
supplier_name = ''
supplier_sku = supplier_part.SKU
# Add manufacturer data to the manufacturer columns
# Generate column names for this supplier
k_sup = manufacturer_headers[0] + "_" + str(idx)
k_sku = manufacturer_headers[1] + "_" + str(idx)
try:
manufacturer_cols[k_sup].update({b_idx: supplier_name})
manufacturer_cols[k_sku].update({b_idx: supplier_sku})
except KeyError:
manufacturer_cols[k_sup] = {b_idx: supplier_name}
manufacturer_cols[k_sku] = {b_idx: supplier_sku}
# Add manufacturer columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items)) add_columns_to_dataset(manufacturer_cols, len(bom_items))
data = dataset.export(fmt) data = dataset.export(fmt)

View File

@ -0,0 +1,27 @@
# Generated by Django 3.2.5 on 2021-11-03 07:03
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('part', '0073_auto_20211013_1048'),
]
operations = [
migrations.CreateModel(
name='PartCategoryStar',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_users', to='part.partcategory', verbose_name='Category')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_categories', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'unique_together': {('category', 'user')},
},
),
]

View File

@ -20,7 +20,7 @@ from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models.signals import pre_delete from django.db.models.signals import pre_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from jinja2 import Template from jinja2 import Template
@ -47,6 +47,7 @@ from InvenTree import validators
from InvenTree.models import InvenTreeTree, InvenTreeAttachment from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money from InvenTree.helpers import decimal2string, normalize, decimal2money
import InvenTree.tasks
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
@ -56,6 +57,7 @@ from company.models import SupplierPart
from stock import models as StockModels from stock import models as StockModels
import common.models import common.models
import part.settings as part_settings import part.settings as part_settings
@ -102,11 +104,11 @@ class PartCategory(InvenTreeTree):
if cascade: if cascade:
""" Select any parts which exist in this category or any child categories """ """ Select any parts which exist in this category or any child categories """
query = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True)) queryset = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True))
else: else:
query = Part.objects.filter(category=self.pk) queryset = Part.objects.filter(category=self.pk)
return query return queryset
@property @property
def item_count(self): def item_count(self):
@ -201,6 +203,60 @@ class PartCategory(InvenTreeTree):
return prefetch.filter(category=self.id) return prefetch.filter(category=self.id)
def get_subscribers(self, include_parents=True):
"""
Return a list of users who subscribe to this PartCategory
"""
cats = self.get_ancestors(include_self=True)
subscribers = set()
if include_parents:
queryset = PartCategoryStar.objects.filter(
category__pk__in=[cat.pk for cat in cats]
)
else:
queryset = PartCategoryStar.objects.filter(
category=self,
)
for result in queryset:
subscribers.add(result.user)
return [s for s in subscribers]
def is_starred_by(self, user, **kwargs):
"""
Returns True if the specified user subscribes to this category
"""
return user in self.get_subscribers(**kwargs)
def set_starred(self, user, status):
"""
Set the "subscription" status of this PartCategory against the specified user
"""
if not user:
return
if self.is_starred_by(user) == status:
return
if status:
PartCategoryStar.objects.create(
category=self,
user=user
)
else:
# Note that this won't actually stop the user being subscribed,
# if the user is subscribed to a parent category
PartCategoryStar.objects.filter(
category=self,
user=user,
).delete()
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log') @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
def before_delete_part_category(sender, instance, using, **kwargs): def before_delete_part_category(sender, instance, using, **kwargs):
@ -332,9 +388,16 @@ class Part(MPTTModel):
context = {} context = {}
context['starred'] = self.isStarredBy(request.user)
context['disabled'] = not self.active context['disabled'] = not self.active
# Subscription status
context['starred'] = self.is_starred_by(request.user)
context['starred_directly'] = context['starred'] and self.is_starred_by(
request.user,
include_variants=False,
include_categories=False
)
# Pre-calculate complex queries so they only need to be performed once # Pre-calculate complex queries so they only need to be performed once
context['total_stock'] = self.total_stock context['total_stock'] = self.total_stock
@ -1040,30 +1103,65 @@ class Part(MPTTModel):
return self.total_stock - self.allocation_count() + self.on_order return self.total_stock - self.allocation_count() + self.on_order
def isStarredBy(self, user): def get_subscribers(self, include_variants=True, include_categories=True):
""" Return True if this part has been starred by a particular user """
try:
PartStar.objects.get(part=self, user=user)
return True
except PartStar.DoesNotExist:
return False
def setStarred(self, user, starred):
""" """
Set the "starred" status of this Part for the given user Return a list of users who are 'subscribed' to this part.
A user may 'subscribe' to this part in the following ways:
a) Subscribing to the part instance directly
b) Subscribing to a template part "above" this part (if it is a variant)
c) Subscribing to the part category that this part belongs to
d) Subscribing to a parent category of the category in c)
"""
subscribers = set()
# Start by looking at direct subscriptions to a Part model
queryset = PartStar.objects.all()
if include_variants:
queryset = queryset.filter(
part__pk__in=[part.pk for part in self.get_ancestors(include_self=True)]
)
else:
queryset = queryset.filter(part=self)
for star in queryset:
subscribers.add(star.user)
if include_categories and self.category:
for sub in self.category.get_subscribers():
subscribers.add(sub)
return [s for s in subscribers]
def is_starred_by(self, user, **kwargs):
"""
Return True if the specified user subscribes to this part
"""
return user in self.get_subscribers(**kwargs)
def set_starred(self, user, status):
"""
Set the "subscription" status of this Part against the specified user
""" """
if not user: if not user:
return return
# Do not duplicate efforts # Already subscribed?
if self.isStarredBy(user) == starred: if self.is_starred_by(user) == status:
return return
if starred: if status:
PartStar.objects.create(part=self, user=user) PartStar.objects.create(part=self, user=user)
else: else:
# Note that this won't actually stop the user being subscribed,
# if the user is subscribed to a parent part or category
PartStar.objects.filter(part=self, user=user).delete() PartStar.objects.filter(part=self, user=user).delete()
def need_to_restock(self): def need_to_restock(self):
@ -1226,6 +1324,17 @@ class Part(MPTTModel):
return query return query
def get_stock_count(self, include_variants=True):
"""
Return the total "in stock" count for this part
"""
entries = self.stock_entries(in_stock=True, include_variants=include_variants)
query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
return query['t']
@property @property
def total_stock(self): def total_stock(self):
""" Return the total stock quantity for this part. """ Return the total stock quantity for this part.
@ -1234,11 +1343,7 @@ class Part(MPTTModel):
- If this part is a "template" (variants exist) then these are counted too - If this part is a "template" (variants exist) then these are counted too
""" """
entries = self.stock_entries(in_stock=True) return self.get_stock_count()
query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
return query['t']
def get_bom_item_filter(self, include_inherited=True): def get_bom_item_filter(self, include_inherited=True):
""" """
@ -1287,6 +1392,27 @@ class Part(MPTTModel):
return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited)) return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited))
def get_installed_part_options(self, include_inherited=True, include_variants=True):
"""
Return a set of all Parts which can be "installed" into this part, based on the BOM.
arguments:
include_inherited - If set, include BomItem entries defined for parent parts
include_variants - If set, include variant parts for BomItems which allow variants
"""
parts = set()
for bom_item in self.get_bom_items(include_inherited=include_inherited):
if include_variants and bom_item.allow_variants:
for part in bom_item.sub_part.get_descendants(include_self=True):
parts.add(part)
else:
parts.add(bom_item.sub_part)
return parts
def get_used_in_filter(self, include_inherited=True): def get_used_in_filter(self, include_inherited=True):
""" """
Return a query filter for all parts that this part is used in. Return a query filter for all parts that this part is used in.
@ -1945,10 +2071,10 @@ class Part(MPTTModel):
if self.variant_of: if self.variant_of:
parts.append(self.variant_of) parts.append(self.variant_of)
siblings = self.get_siblings(include_self=False) siblings = self.get_siblings(include_self=False)
for sib in siblings: for sib in siblings:
parts.append(sib) parts.append(sib)
filtered_parts = Part.objects.filter(pk__in=[part.pk for part in parts]) filtered_parts = Part.objects.filter(pk__in=[part.pk for part in parts])
@ -1988,6 +2114,26 @@ class Part(MPTTModel):
def related_count(self): def related_count(self):
return len(self.get_related_parts()) return len(self.get_related_parts())
def is_part_low_on_stock(self):
"""
Returns True if the total stock for this part is less than the minimum stock level
"""
return self.get_stock_count() < self.minimum_stock
@receiver(post_save, sender=Part, dispatch_uid='part_post_save_log')
def after_save_part(sender, instance: Part, created, **kwargs):
"""
Function to be executed after a Part is saved
"""
if not created:
# Check part stock only if we are *updating* the part (not creating it)
# Run this check in the background
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance)
def attach_file(instance, filename): def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment """ Function for storing a file for a PartAttachment
@ -2059,10 +2205,9 @@ class PartInternalPriceBreak(common.models.PriceBreak):
class PartStar(models.Model): class PartStar(models.Model):
""" A PartStar object creates a relationship between a User and a Part. """ A PartStar object creates a subscription relationship between a User and a Part.
It is used to designate a Part as 'starred' (or favourited) for a given User, It is used to designate a Part as 'subscribed' for a given User.
so that the user can track a list of their favourite parts.
Attributes: Attributes:
part: Link to a Part object part: Link to a Part object
@ -2074,7 +2219,30 @@ class PartStar(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts') user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts')
class Meta: class Meta:
unique_together = ['part', 'user'] unique_together = [
'part',
'user'
]
class PartCategoryStar(models.Model):
"""
A PartCategoryStar creates a subscription relationship between a User and a PartCategory.
Attributes:
category: Link to a PartCategory object
user: Link to a User object
"""
category = models.ForeignKey(PartCategory, on_delete=models.CASCADE, verbose_name=_('Category'), related_name='starred_users')
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories')
class Meta:
unique_together = [
'category',
'user',
]
class PartTestTemplate(models.Model): class PartTestTemplate(models.Model):

View File

@ -33,12 +33,25 @@ from .models import (BomItem, BomItemSubstitute,
class CategorySerializer(InvenTreeModelSerializer): class CategorySerializer(InvenTreeModelSerializer):
""" Serializer for PartCategory """ """ Serializer for PartCategory """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def get_starred(self, category):
"""
Return True if the category is directly "starred" by the current user
"""
return category in self.context.get('starred_categories', [])
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
parts = serializers.IntegerField(source='item_count', read_only=True) parts = serializers.IntegerField(source='item_count', read_only=True)
level = serializers.IntegerField(read_only=True) level = serializers.IntegerField(read_only=True)
starred = serializers.SerializerMethodField()
class Meta: class Meta:
model = PartCategory model = PartCategory
fields = [ fields = [
@ -51,6 +64,7 @@ class CategorySerializer(InvenTreeModelSerializer):
'parent', 'parent',
'parts', 'parts',
'pathstring', 'pathstring',
'starred',
'url', 'url',
] ]
@ -241,6 +255,9 @@ class PartSerializer(InvenTreeModelSerializer):
to reduce database trips. to reduce database trips.
""" """
# TODO: Update the "in_stock" annotation to include stock for variants of the part
# Ref: https://github.com/inventree/InvenTree/issues/2240
# Annotate with the total 'in stock' quantity # Annotate with the total 'in stock' quantity
queryset = queryset.annotate( queryset = queryset.annotate(
in_stock=Coalesce( in_stock=Coalesce(

77
InvenTree/part/tasks.py Normal file
View File

@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import logging
from datetime import timedelta
from django.utils.translation import ugettext_lazy as _
from django.template.loader import render_to_string
from allauth.account.models import EmailAddress
from common.models import NotificationEntry
import InvenTree.helpers
import InvenTree.tasks
import part.models
logger = logging.getLogger("inventree")
def notify_low_stock(part: part.models.Part):
"""
Notify users who have starred a part when its stock quantity falls below the minimum threshold
"""
# Check if we have notified recently...
delta = timedelta(days=1)
if NotificationEntry.check_recent('part.notify_low_stock', part.pk, delta):
logger.info(f"Low stock notification has recently been sent for '{part.full_name}' - SKIPPING")
return
logger.info(f"Sending low stock notification email for {part.full_name}")
# Get a list of users who are subcribed to this part
subscribers = part.get_subscribers()
emails = EmailAddress.objects.filter(
user__in=subscribers,
)
# TODO: In the future, include the part image in the email template
if len(emails) > 0:
logger.info(f"Notify users regarding low stock of {part.name}")
context = {
# Pass the "Part" object through to the template context
'part': part,
'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()),
}
subject = "[InvenTree] " + _("Low stock notification")
html_message = render_to_string('email/low_stock_notification.html', context)
recipients = emails.values_list('email', flat=True)
InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message)
NotificationEntry.notify('part.notify_low_stock', part.pk)
def notify_low_stock_if_required(part: part.models.Part):
"""
Check if the stock quantity has fallen below the minimum threshold of part.
If true, notify the users who have subscribed to the part
"""
# Run "up" the tree, to allow notification for "parent" parts
parts = part.get_ancestors(include_self=True, ascending=True)
for p in parts:
if p.is_part_low_on_stock():
InvenTree.tasks.offload_task(
'part.tasks.notify_low_stock',
p
)

View File

@ -35,10 +35,7 @@
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
{% include "filter_list.html" with id="bom" %}
<div class='filter-list' id='filter-list-bom'>
<!-- Empty div (will be filled out with avilable BOM filters) -->
</div>
</div> </div>
</div> </div>

View File

@ -8,58 +8,55 @@
{% include "sidebar_link.html" with url=url text="Return to BOM" icon="fa-undo" %} {% include "sidebar_link.html" with url=url text="Return to BOM" icon="fa-undo" %}
{% endblock %} {% endblock %}
{% block page_content %} {% block heading %}
{% trans "Upload Bill of Materials" %}
{% endblock %}
<div class='panel' id='panel-upload-file'> {% block actions %}
<div class='panel-heading'> {% endblock %}
{% block heading %}
<h4>{% trans "Upload Bill of Materials" %}</h4> {% block page_info %}
{{ wizard.form.media }} <div class='panel-content'>
{% endblock %} <p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
{% block form_buttons_top %}
{% endblock form_buttons_top %}
{% block form_alert %}
<div class='alert alert-info alert-block'>
<strong>{% trans "Requirements for BOM upload" %}:</strong>
<ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href="/part/bom_template/">{% trans "BOM Upload Template" %}</a></strong></li>
<li>{% trans "Each part must already exist in the database" %}</li>
</ul>
</div> </div>
<div class='panel-content'> {% endblock %}
{% block details %}
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} <table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{% if description %}- {{ description }}{% endif %}</p> {{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data"> {% block form_buttons_bottom %}
{% csrf_token %} {% if wizard.steps.prev %}
{% load crispy_forms_tags %} <button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
{% block form_buttons_top %} <button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
{% endblock form_buttons_top %} </form>
{% endblock form_buttons_bottom %}
{% block form_alert %} </div>
<div class='alert alert-info alert-block'> {% endblock page_info %}
<strong>{% trans "Requirements for BOM upload" %}:</strong>
<ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href="/part/bom_template/">{% trans "BOM Upload Template" %}</a></strong></li>
<li>{% trans "Each part must already exist in the database" %}</li>
</ul>
</div>
{% endblock %}
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
{% block form_buttons_bottom %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
{% endblock details %}
</div>
{% endblock page_content %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
enableSidebar('bom-upload');
{% endblock js_ready %} {% endblock js_ready %}

View File

@ -20,15 +20,37 @@
{% include "admin_button.html" with url=url %} {% include "admin_button.html" with url=url %}
{% endif %} {% endif %}
{% if category %} {% if category %}
{% if roles.part_category.change %} {% if starred_directly %}
<button class='btn btn-outline-secondary' id='cat-edit' title='{% trans "Edit part category" %}'> <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this category" %}'>
<span class='fas fa-edit'/> <span id='category-star-icon' class='fas fa-bell icon-green'></span>
</button>
{% elif starred %}
<button type='button' class='btn btn-outline-secondary' title='{% trans "You are subscribed to notifications for this category" %}' disabled='true'>
<span class='fas fa-bell icon-green'></span>
</button>
{% else %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to notifications for this category" %}'>
<span id='category-star-icon' class='fa fa-bell-slash'/>
</button> </button>
{% endif %} {% endif %}
{% if roles.part_category.delete %} {% if roles.part_category.change or roles.part_category.delete %}
<button class='btn btn-outline-secondary' id='cat-delete' title='{% trans "Delete part category" %}'> <div class='btn-group' role='group'>
<span class='fas fa-trash-alt icon-red'/> <button id='category-options' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Category Actions" %}'>
</button> <span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
{% if roles.part_category.change %}
<li><a class='dropdown-item' href='#' id='cat-edit' title='{% trans "Edit category" %}'>
<span class='fas fa-edit icon-green'></span> {% trans "Edit Category" %}
</a></li>
{% endif %}
{% if roles.part_category.delete %}
<li><a class='dropdown-item' href='#' id='cat-delete' title='{% trans "Delete category" %}'>
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Category" %}
</a></li>
{% endif %}
</ul>
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if roles.part_category.add %} {% if roles.part_category.add %}
@ -116,7 +138,7 @@
<div class='panel panel-hidden' id='panel-parts'> <div class='panel panel-hidden' id='panel-parts'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Parts" %}</h4> <h4>{% trans "Parts" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -142,13 +164,13 @@
<li><a class='dropdown-item' href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
{% endif %} {% endif %}
<li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>{% trans "Print Labels" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>{% trans "Print Labels" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
</ul> </ul>
</div> </div>
<div class='filter-list' id='filter-list-parts'> {% include "filter_list.html" with id="parts" %}
<!-- Empty div -->
</div>
</div> </div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
@ -174,9 +196,7 @@
<div class='panel-content'> <div class='panel-content'>
<div id='subcategory-button-toolbar'> <div id='subcategory-button-toolbar'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
<div class='filter-list' id='filter-list-category'> {% include "filter_list.html" with id="category" %}
<!-- An empty div in which the filter list will be constructed -->
</div>
</div> </div>
</div> </div>
@ -202,6 +222,14 @@
data: {{ parameters|safe }}, data: {{ parameters|safe }},
} }
); );
$("#toggle-starred").click(function() {
toggleStar({
url: '{% url "api-part-category-detail" category.pk %}',
button: '#category-star-icon'
});
});
{% endif %} {% endif %}
enableSidebar('category'); enableSidebar('category');
@ -214,7 +242,8 @@
{% else %} {% else %}
parent: null, parent: null,
{% endif %} {% endif %}
} },
allowTreeView: true,
} }
); );

View File

@ -20,13 +20,6 @@
<!-- Details Table --> <!-- Details Table -->
<table class="table table-striped table-condensed"> <table class="table table-striped table-condensed">
<col width='25'> <col width='25'>
{% if part.IPN %}
<tr>
<td><span class='fas fa-tag'></span></td>
<td>{% trans "IPN" %}</td>
<td>{{ part.IPN }}{% include "clip.html"%}</td>
</tr>
{% endif %}
<tr> <tr>
<td><span class='fas fa-shapes'></span></td> <td><span class='fas fa-shapes'></span></td>
<td>{% trans "Name" %}</td> <td>{% trans "Name" %}</td>
@ -37,6 +30,22 @@
<td>{% trans "Description" %}</td> <td>{% trans "Description" %}</td>
<td>{{ part.description }}{% include "clip.html"%}</td> <td>{{ part.description }}{% include "clip.html"%}</td>
</tr> </tr>
{% if part.category %}
<tr>
<td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Category" %}</td>
<td>
<a href='{% url "category-detail" part.category.pk %}'>{{ part.category.name }}</a>
</td>
</tr>
{% endif %}
{% if part.IPN %}
<tr>
<td><span class='fas fa-tag'></span></td>
<td>{% trans "IPN" %}</td>
<td>{{ part.IPN }}{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if part.revision %} {% if part.revision %}
<tr> <tr>
<td><span class='fas fa-code-branch'></span></td> <td><span class='fas fa-code-branch'></span></td>
@ -44,6 +53,20 @@
<td>{{ part.revision }}{% include "clip.html"%}</td> <td>{{ part.revision }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.units %}
<tr>
<td></td>
<td>{% trans "Units" %}</td>
<td>{{ part.units }}</td>
</tr>
{% endif %}
{% if part.minimum_stock %}
<tr>
<td><span class='fas fa-flag'></span></td>
<td>{% trans "Minimum stock level" %}</td>
<td>{{ part.minimum_stock }}</td>
</tr>
{% endif %}
{% if part.keywords %} {% if part.keywords %}
<tr> <tr>
<td><span class='fas fa-key'></span></td> <td><span class='fas fa-key'></span></td>
@ -64,7 +87,7 @@
<td> <td>
{{ part.creation_date }} {{ part.creation_date }}
{% if part.creation_user %} {% if part.creation_user %}
<span class='badge'>{{ part.creation_user }}</span> <span class='badge badge-right rounded-pill bg-dark'>{{ part.creation_user }}</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -79,7 +102,9 @@
<tr> <tr>
<td><span class='fas fa-search-location'></span></td> <td><span class='fas fa-search-location'></span></td>
<td>{% trans "Default Location" %}</td> <td>{% trans "Default Location" %}</td>
<td>{{ part.default_location }}</td> <td>
<a href='{% url "stock-location-detail" part.default_location.pk %}'>{{ part.default_location }}</a>
</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.default_supplier %} {% if part.default_supplier %}
@ -95,7 +120,15 @@
<div class='panel panel-hidden' id='panel-part-stock'> <div class='panel panel-hidden' id='panel-part-stock'>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Part Stock" %}</h4> <div class='d-flex flex-wrap'>
<h4>{% trans "Part Stock" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='new-stock-item' title='{% trans "Create new stock item" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
</button>
</div>
</div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% if part.is_template %} {% if part.is_template %}
@ -109,7 +142,7 @@
<div class='panel panel-hidden' id='panel-test-templates'> <div class='panel panel-hidden' id='panel-test-templates'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Part Test Templates" %}</h4> <h4>{% trans "Part Test Templates" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -123,10 +156,8 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='test-button-toolbar'> <div id='test-button-toolbar'>
<div class='button-toolbar container-fluid' style="float: right;"> <div class='btn-group' role='group'>
<div class='filter-list' id='filter-list-parttests'> {% include "filter_list.html" with id="parttests" %}
<!-- Empty div -->
</div>
</div> </div>
</div> </div>
@ -136,7 +167,7 @@
<div class='panel panel-hidden' id='panel-purchase-orders'> <div class='panel panel-hidden' id='panel-purchase-orders'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Purchase Orders" %}</h4> <h4>{% trans "Purchase Orders" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -149,9 +180,7 @@
<div class='panel-content'> <div class='panel-content'>
<div id='po-button-bar'> <div id='po-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='filter-list' id='filter-list-purchaseorder'> {% include "filter_list.html" with id="purchaseorder" %}
<!-- An empty div in which the filter list will be constructed -->
</div>
</div> </div>
</div> </div>
@ -166,13 +195,8 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='so-button-bar'> <div id='so-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='btn-group' role='group'>
{% if 0 %} {% include "filter_list.html" with id="salesorder" %}
<button class='btn btn-success' type='button' id='part-order2' title='{% trans "New sales order" %}'>{% trans "New Order" %}</button>
{% endif %}
<div class='filter-list' id='filter-list-salesorder'>
<!-- An empty div in which the filter list will be constructed -->
</div>
</div> </div>
</div> </div>
@ -221,7 +245,7 @@
<div class='panel panel-hidden' id='panel-variants'> <div class='panel panel-hidden' id='panel-variants'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Part Variants" %}</h4> <h4>{% trans "Part Variants" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -238,9 +262,7 @@
<div class='button-toolbar container-fluid'> <div class='button-toolbar container-fluid'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
</div> </div>
<div class='filter-list' id='filter-list-variants'> {% include "filter_list.html" with id="variants" %}
<!-- Empty div (will be filled out with available BOM filters) -->
</div>
</div> </div>
</div> </div>
@ -251,7 +273,7 @@
<div class='panel panel-hidden' id='panel-part-parameters'> <div class='panel panel-hidden' id='panel-part-parameters'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Parameters" %}</h4> <h4>{% trans "Parameters" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -274,7 +296,7 @@
<div class='panel panel-hidden' id='panel-part-attachments'> <div class='panel panel-hidden' id='panel-part-attachments'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4> <h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -289,7 +311,7 @@
<div class='panel panel-hidden' id='panel-related-parts'> <div class='panel panel-hidden' id='panel-related-parts'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Related Parts" %}</h4> <h4>{% trans "Related Parts" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -303,10 +325,8 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='related-button-bar'> <div id='related-button-bar'>
<div class='button-toolbar container-fluid' style='float: left;'> <div class='btn-group' role='group'>
<div class='filter-list' id='filter-list-related'> {% include "filter_list.html" with id="related" %}
<!-- An empty div in which the filter list will be constructed -->
</div>
</div> </div>
</div> </div>
@ -342,7 +362,7 @@
<div class='panel panel-hidden' id='panel-bom'> <div class='panel panel-hidden' id='panel-bom'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Bill of Materials" %}</h4> <h4>{% trans "Bill of Materials" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -353,7 +373,9 @@
</button> </button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='download-bom'><span class='fas fa-file-download'></span> {% trans "Export BOM" %}</a></li> <li><a class='dropdown-item' href='#' id='download-bom'><span class='fas fa-file-download'></span> {% trans "Export BOM" %}</a></li>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='print-bom-report'><span class='fas fa-file-pdf'></span> {% trans "Print BOM Report" %}</a></li> <li><a class='dropdown-item' href='#' id='print-bom-report'><span class='fas fa-file-pdf'></span> {% trans "Print BOM Report" %}</a></li>
{% endif %}
</ul> </ul>
</div> </div>
<!-- Actions menu --> <!-- Actions menu -->
@ -391,8 +413,8 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='assembly-button-toolbar'> <div id='assembly-button-toolbar'>
<div class='filter-list' id='filter-list-usedin'> <div class='btn-group' role='group'>
<!-- Empty div (will be filled out with avilable BOM filters) --> {% include "filter_list.html" with id="usedin" %}
</div> </div>
</div> </div>
@ -403,7 +425,7 @@
<div class='panel panel-hidden' id='panel-build-orders'> <div class='panel panel-hidden' id='panel-build-orders'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Part Builds" %}</h4> <h4>{% trans "Part Builds" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -419,10 +441,8 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='build-button-toolbar'> <div id='build-button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right';> <div class='btn-group' role='group'>
<div class='filter-list' id='filter-list-build'> {% include "filter_list.html" with id="build" %}
<!-- Empty div for filters -->
</div>
</div> </div>
</div> </div>
@ -440,7 +460,7 @@
<div class='panel panel-hidden' id='panel-suppliers'> <div class='panel panel-hidden' id='panel-suppliers'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Part Suppliers" %}</h4> <h4>{% trans "Part Suppliers" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -467,7 +487,7 @@
</div> </div>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Part Manufacturers" %}</h4> <h4>{% trans "Part Manufacturers" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -748,9 +768,11 @@
); );
}); });
{% if report_enabled %}
$("#print-bom-report").click(function() { $("#print-bom-report").click(function() {
printBomReports([{{ part.pk }}]); printBomReports([{{ part.pk }}]);
}); });
{% endif %}
}); });
// Load the "related parts" tab // Load the "related parts" tab
@ -866,11 +888,13 @@
}); });
onPanelLoad("part-stock", function() { onPanelLoad("part-stock", function() {
$('#add-stock-item').click(function () { $('#new-stock-item').click(function () {
createNewStockItem({ createNewStockItem({
reload: true,
data: { data: {
part: {{ part.id }}, part: {{ part.id }},
{% if part.default_location %}
location: {{ part.default_location.pk }},
{% endif %}
} }
}); });
}); });
@ -898,7 +922,6 @@
$('#item-create').click(function () { $('#item-create').click(function () {
createNewStockItem({ createNewStockItem({
reload: true,
data: { data: {
part: {{ part.id }}, part: {{ part.id }},
} }

View File

@ -23,9 +23,19 @@
{% include "admin_button.html" with url=url %} {% include "admin_button.html" with url=url %}
{% endif %} {% endif %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Star this part" %}'> {% if starred_directly %}
<span id='part-star-icon' class='fas fa-star {% if starred %}icon-yellow{% endif %}'/> <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this part" %}'>
<span id='part-star-icon' class='fas fa-bell icon-green'/>
</button> </button>
{% elif starred %}
<button type='button' class='btn btn-outline-secondary' title='{% trans "You are subscribed to notifications for this part" %}' disabled='true'>
<span class='fas fa-bell icon-green'></span>
</button>
{% else %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to notifications for this part" %}'>
<span id='part-star-icon' class='fa fa-bell-slash'/>
</button>
{% endif %}
{% if barcodes %} {% if barcodes %}
<!-- Barcode actions menu --> <!-- Barcode actions menu -->
@ -137,8 +147,6 @@
</div> </div>
</h4> </h4>
<!-- Part info messages --> <!-- Part info messages -->
<div class='info-messages'> <div class='info-messages'>
{% if part.variant_of %} {% if part.variant_of %}
@ -164,6 +172,13 @@
<td>{% trans "In Stock" %}</td> <td>{% trans "In Stock" %}</td>
<td>{% include "part/stock_count.html" %}</td> <td>{% include "part/stock_count.html" %}</td>
</tr> </tr>
{% if part.minimum_stock %}
<tr>
<td><span class='fas fa-flag'></span></td>
<td>{% trans "Minimum Stock" %}</td>
<td>{{ part.minimum_stock }}</td>
</tr>
{% endif %}
{% if on_order > 0 %} {% if on_order > 0 %}
<tr> <tr>
<td><span class='fas fa-shopping-cart'></span></td> <td><span class='fas fa-shopping-cart'></span></td>
@ -310,7 +325,7 @@
$("#toggle-starred").click(function() { $("#toggle-starred").click(function() {
toggleStar({ toggleStar({
part: {{ part.id }}, url: '{% url "api-part-detail" part.pk %}',
button: '#part-star-icon', button: '#part-star-icon',
}); });
}); });

View File

@ -122,6 +122,12 @@ def inventree_title(*args, **kwargs):
return version.inventreeInstanceTitle() return version.inventreeInstanceTitle()
@register.simple_tag()
def inventree_base_url(*args, **kwargs):
""" Return the INVENTREE_BASE_URL setting """
return InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
@register.simple_tag() @register.simple_tag()
def python_version(*args, **kwargs): def python_version(*args, **kwargs):
""" """

View File

@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
import os import os
from .models import Part, PartCategory, PartTestTemplate from .models import Part, PartCategory, PartCategoryStar, PartStar, PartTestTemplate
from .models import rename_part_image from .models import rename_part_image
from .templatetags import inventree_extras from .templatetags import inventree_extras
@ -347,3 +347,120 @@ class PartSettingsTest(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C') part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
part.full_clean() part.full_clean()
class PartSubscriptionTests(TestCase):
fixtures = [
'location',
'category',
'part',
]
def setUp(self):
# Create a user for auth
user = get_user_model()
self.user = user.objects.create_user(
username='testuser',
email='test@testing.com',
password='password',
is_staff=True
)
# electronics / IC / MCU
self.category = PartCategory.objects.get(pk=4)
self.part = Part.objects.create(
category=self.category,
name='STM32F103',
description='Currently worth a lot of money',
is_template=True,
)
def test_part_subcription(self):
"""
Test basic subscription against a part
"""
# First check that the user is *not* subscribed to the part
self.assertFalse(self.part.is_starred_by(self.user))
# Now, subscribe directly to the part
self.part.set_starred(self.user, True)
self.assertEqual(PartStar.objects.count(), 1)
self.assertTrue(self.part.is_starred_by(self.user))
# Now, unsubscribe
self.part.set_starred(self.user, False)
self.assertFalse(self.part.is_starred_by(self.user))
def test_variant_subscription(self):
"""
Test subscription against a parent part
"""
# Construct a sub-part to star against
sub_part = Part.objects.create(
name='sub_part',
description='a sub part',
variant_of=self.part,
)
self.assertFalse(sub_part.is_starred_by(self.user))
# Subscribe to the "parent" part
self.part.set_starred(self.user, True)
self.assertTrue(self.part.is_starred_by(self.user))
self.assertTrue(sub_part.is_starred_by(self.user))
def test_category_subscription(self):
"""
Test subscription against a PartCategory
"""
self.assertEqual(PartCategoryStar.objects.count(), 0)
self.assertFalse(self.part.is_starred_by(self.user))
self.assertFalse(self.category.is_starred_by(self.user))
# Subscribe to the direct parent category
self.category.set_starred(self.user, True)
self.assertEqual(PartStar.objects.count(), 0)
self.assertEqual(PartCategoryStar.objects.count(), 1)
self.assertTrue(self.category.is_starred_by(self.user))
self.assertTrue(self.part.is_starred_by(self.user))
# Check that the "parent" category is not starred
self.assertFalse(self.category.parent.is_starred_by(self.user))
# Un-subscribe
self.category.set_starred(self.user, False)
self.assertFalse(self.category.is_starred_by(self.user))
self.assertFalse(self.part.is_starred_by(self.user))
def test_parent_category_subscription(self):
"""
Check that a parent category can be subscribed to
"""
# Top-level "electronics" category
cat = PartCategory.objects.get(pk=1)
cat.set_starred(self.user, True)
# Check base category
self.assertTrue(cat.is_starred_by(self.user))
# Check lower level category
self.assertTrue(self.category.is_starred_by(self.user))
# Check part
self.assertTrue(self.part.is_starred_by(self.user))

View File

@ -42,11 +42,12 @@ from common.files import FileManager
from common.views import FileManagementFormView, FileManagementAjaxView from common.views import FileManagementFormView, FileManagementAjaxView
from common.forms import UploadFileForm, MatchFieldForm from common.forms import UploadFileForm, MatchFieldForm
from stock.models import StockLocation from stock.models import StockItem, StockLocation
import common.settings as inventree_settings import common.settings as inventree_settings
from . import forms as part_forms from . import forms as part_forms
from . import settings as part_settings
from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat
from order.models import PurchaseOrderLineItem from order.models import PurchaseOrderLineItem
@ -245,6 +246,7 @@ class PartImport(FileManagementFormView):
'Category', 'Category',
'default_location', 'default_location',
'default_supplier', 'default_supplier',
'variant_of',
] ]
OPTIONAL_HEADERS = [ OPTIONAL_HEADERS = [
@ -256,6 +258,17 @@ class PartImport(FileManagementFormView):
'minimum_stock', 'minimum_stock',
'Units', 'Units',
'Notes', 'Notes',
'Active',
'base_cost',
'Multiple',
'Assembly',
'Component',
'is_template',
'Purchaseable',
'Salable',
'Trackable',
'Virtual',
'Stock',
] ]
name = 'part' name = 'part'
@ -284,6 +297,18 @@ class PartImport(FileManagementFormView):
'category': 'category', 'category': 'category',
'default_location': 'default_location', 'default_location': 'default_location',
'default_supplier': 'default_supplier', 'default_supplier': 'default_supplier',
'variant_of': 'variant_of',
'active': 'active',
'base_cost': 'base_cost',
'multiple': 'multiple',
'assembly': 'assembly',
'component': 'component',
'is_template': 'is_template',
'purchaseable': 'purchaseable',
'salable': 'salable',
'trackable': 'trackable',
'virtual': 'virtual',
'stock': 'stock',
} }
file_manager_class = PartFileManager file_manager_class = PartFileManager
@ -299,6 +324,8 @@ class PartImport(FileManagementFormView):
self.matches['default_location'] = ['name__contains'] self.matches['default_location'] = ['name__contains']
self.allowed_items['default_supplier'] = SupplierPart.objects.all() self.allowed_items['default_supplier'] = SupplierPart.objects.all()
self.matches['default_supplier'] = ['SKU__contains'] self.matches['default_supplier'] = ['SKU__contains']
self.allowed_items['variant_of'] = Part.objects.all()
self.matches['variant_of'] = ['name__contains']
# setup # setup
self.file_manager.setup() self.file_manager.setup()
@ -364,9 +391,29 @@ class PartImport(FileManagementFormView):
category=optional_matches['Category'], category=optional_matches['Category'],
default_location=optional_matches['default_location'], default_location=optional_matches['default_location'],
default_supplier=optional_matches['default_supplier'], default_supplier=optional_matches['default_supplier'],
variant_of=optional_matches['variant_of'],
active=str2bool(part_data.get('active', True)),
base_cost=part_data.get('base_cost', 0),
multiple=part_data.get('multiple', 1),
assembly=str2bool(part_data.get('assembly', part_settings.part_assembly_default())),
component=str2bool(part_data.get('component', part_settings.part_component_default())),
is_template=str2bool(part_data.get('is_template', part_settings.part_template_default())),
purchaseable=str2bool(part_data.get('purchaseable', part_settings.part_purchaseable_default())),
salable=str2bool(part_data.get('salable', part_settings.part_salable_default())),
trackable=str2bool(part_data.get('trackable', part_settings.part_trackable_default())),
virtual=str2bool(part_data.get('virtual', part_settings.part_virtual_default())),
) )
try: try:
new_part.save() new_part.save()
# add stock item if set
if part_data.get('stock', None):
stock = StockItem(
part=new_part,
location=new_part.default_location,
quantity=int(part_data.get('stock', 1)),
)
stock.save()
import_done += 1 import_done += 1
except ValidationError as _e: except ValidationError as _e:
import_error.append(', '.join(set(_e.messages))) import_error.append(', '.join(set(_e.messages)))
@ -412,6 +459,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
part = self.get_object() part = self.get_object()
ctx = part.get_context_data(self.request) ctx = part.get_context_data(self.request)
context.update(**ctx) context.update(**ctx)
# Pricing information # Pricing information
@ -1469,18 +1517,29 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
if category: if category:
cascade = kwargs.get('cascade', True) cascade = kwargs.get('cascade', True)
# Prefetch parts parameters # Prefetch parts parameters
parts_parameters = category.prefetch_parts_parameters(cascade=cascade) parts_parameters = category.prefetch_parts_parameters(cascade=cascade)
# Get table headers (unique parameters names) # Get table headers (unique parameters names)
context['headers'] = category.get_unique_parameters(cascade=cascade, context['headers'] = category.get_unique_parameters(cascade=cascade,
prefetch=parts_parameters) prefetch=parts_parameters)
# Insert part information # Insert part information
context['headers'].insert(0, 'description') context['headers'].insert(0, 'description')
context['headers'].insert(0, 'part') context['headers'].insert(0, 'part')
# Get parameters data # Get parameters data
context['parameters'] = category.get_parts_parameters(cascade=cascade, context['parameters'] = category.get_parts_parameters(cascade=cascade,
prefetch=parts_parameters) prefetch=parts_parameters)
# Insert "starred" information
context['starred'] = category.is_starred_by(self.request.user)
context['starred_directly'] = context['starred'] and category.is_starred_by(
self.request.user,
include_parents=False,
)
return context return context

View File

@ -257,7 +257,6 @@ class ReportPrintMixin:
pages = [] pages = []
try: try:
pdf = outputs[0].get_document().copy(pages).write_pdf()
if len(outputs) > 1: if len(outputs) > 1:
# If more than one output is generated, merge them into a single file # If more than one output is generated, merge them into a single file
@ -265,6 +264,8 @@ class ReportPrintMixin:
doc = output.get_document() doc = output.get_document()
for page in doc.pages: for page in doc.pages:
pages.append(page) pages.append(page)
pdf = outputs[0].get_document().copy(pages).write_pdf()
else: else:
pdf = outputs[0].get_document().write_pdf() pdf = outputs[0].get_document().write_pdf()

View File

@ -14,6 +14,8 @@ from stock.models import StockItem
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
import InvenTree.helpers
register = template.Library() register = template.Library()
@ -119,18 +121,10 @@ def internal_link(link, text):
text = str(text) text = str(text)
base_url = InvenTreeSetting.get_setting('INVENTREE_BASE_URL') url = InvenTree.helpers.construct_absolute_url(link)
# If the base URL is not set, just return the text # If the base URL is not set, just return the text
if not base_url: if not url:
return text return text
if not base_url.endswith('/'):
base_url += '/'
if base_url.endswith('/') and link.startswith('/'):
link = link[1:]
url = f"{base_url}{link}"
return mark_safe(f'<a href="{url}">{text}</a>') return mark_safe(f'<a href="{url}">{text}</a>')

View File

@ -117,6 +117,8 @@ class StockItemResource(ModelResource):
exclude = [ exclude = [
# Exclude MPTT internal model fields # Exclude MPTT internal model fields
'lft', 'rght', 'tree_id', 'level', 'lft', 'rght', 'tree_id', 'level',
# Exclude internal fields
'serial_int',
] ]

View File

@ -7,42 +7,44 @@ from __future__ import unicode_literals
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.core.exceptions import ValidationError as DjangoValidationError
from django.conf.urls import url, include from django.conf.urls import url, include
from django.http import JsonResponse from django.http import JsonResponse
from django.db.models import Q from django.db.models import Q
from django.db import transaction
from django.utils.translation import ugettext_lazy as _
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
from rest_framework import status from rest_framework import status
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import generics, filters from rest_framework import generics, filters
from django_filters.rest_framework import DjangoFilterBackend import common.settings
from django_filters import rest_framework as rest_filters import common.models
from .models import StockLocation, StockItem
from .models import StockItemTracking
from .models import StockItemAttachment
from .models import StockItemTestResult
from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from company.serializers import CompanySerializer, SupplierPartSerializer from company.serializers import CompanySerializer, SupplierPartSerializer
from InvenTree.helpers import str2bool, isNull, extract_serial_numbers
from InvenTree.api import AttachmentMixin
from InvenTree.filters import InvenTreeOrderingFilter
from order.models import PurchaseOrder from order.models import PurchaseOrder
from order.models import SalesOrder, SalesOrderAllocation from order.models import SalesOrder, SalesOrderAllocation
from order.serializers import POSerializer from order.serializers import POSerializer
import common.settings from part.models import BomItem, Part, PartCategory
import common.models from part.serializers import PartBriefSerializer
from stock.models import StockLocation, StockItem
from stock.models import StockItemTracking
from stock.models import StockItemAttachment
from stock.models import StockItemTestResult
import stock.serializers as StockSerializers import stock.serializers as StockSerializers
from InvenTree.helpers import str2bool, isNull
from InvenTree.api import AttachmentMixin
from InvenTree.filters import InvenTreeOrderingFilter
class StockDetail(generics.RetrieveUpdateDestroyAPIView): class StockDetail(generics.RetrieveUpdateDestroyAPIView):
""" API detail endpoint for Stock object """ API detail endpoint for Stock object
@ -99,6 +101,27 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
instance.mark_for_deletion() instance.mark_for_deletion()
class StockItemSerialize(generics.CreateAPIView):
"""
API endpoint for serializing a stock item
"""
queryset = StockItem.objects.none()
serializer_class = StockSerializers.SerializeStockItemSerializer
def get_serializer_context(self):
context = super().get_serializer_context()
context['request'] = self.request
try:
context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return context
class StockAdjustView(generics.CreateAPIView): class StockAdjustView(generics.CreateAPIView):
""" """
A generic class for handling stocktake actions. A generic class for handling stocktake actions.
@ -380,28 +403,91 @@ class StockList(generics.ListCreateAPIView):
""" """
user = request.user user = request.user
data = request.data
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
item = serializer.save() # Check if a set of serial numbers was provided
serial_numbers = data.get('serial_numbers', '')
# A location was *not* specified - try to infer it quantity = data.get('quantity', None)
if 'location' not in request.data:
item.location = item.part.get_default_location()
# An expiry date was *not* specified - try to infer it! if quantity is None:
if 'expiry_date' not in request.data: raise ValidationError({
'quantity': _('Quantity is required'),
})
if item.part.default_expiry > 0: notes = data.get('notes', '')
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
# Finally, save the item serials = None
item.save(user=user)
# Return a response if serial_numbers:
headers = self.get_success_headers(serializer.data) # If serial numbers are specified, check that they match!
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) try:
serials = extract_serial_numbers(serial_numbers, data['quantity'])
except DjangoValidationError as e:
raise ValidationError({
'quantity': e.messages,
'serial_numbers': e.messages,
})
with transaction.atomic():
# Create an initial stock item
item = serializer.save()
# A location was *not* specified - try to infer it
if 'location' not in data:
item.location = item.part.get_default_location()
# An expiry date was *not* specified - try to infer it!
if 'expiry_date' not in data:
if item.part.default_expiry > 0:
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
# Finally, save the item (with user information)
item.save(user=user)
if serials:
"""
Serialize the stock, if required
- Note that the "original" stock item needs to be created first, so it can be serialized
- It is then immediately deleted
"""
try:
item.serializeStock(
quantity,
serials,
user,
notes=notes,
location=item.location,
)
headers = self.get_success_headers(serializer.data)
# Delete the original item
item.delete()
response_data = {
'quantity': quantity,
'serial_numbers': serials,
}
return Response(response_data, status=status.HTTP_201_CREATED, headers=headers)
except DjangoValidationError as e:
raise ValidationError({
'quantity': e.messages,
'serial_numbers': e.messages,
})
# Return a response
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
""" """
@ -790,6 +876,7 @@ class StockList(generics.ListCreateAPIView):
ordering_field_aliases = { ordering_field_aliases = {
'SKU': 'supplier_part__SKU', 'SKU': 'supplier_part__SKU',
'stock': ['quantity', 'serial_int', 'serial'],
} }
ordering_fields = [ ordering_fields = [
@ -801,6 +888,7 @@ class StockList(generics.ListCreateAPIView):
'stocktake_date', 'stocktake_date',
'expiry_date', 'expiry_date',
'quantity', 'quantity',
'stock',
'status', 'status',
'SKU', 'SKU',
] ]
@ -1085,8 +1173,11 @@ stock_api_urls = [
url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'), url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
])), ])),
# Detail for a single stock item # Detail views for a single stock item
url(r'^(?P<pk>\d+)/', StockDetail.as_view(), name='api-stock-detail'), url(r'^(?P<pk>\d+)/', include([
url(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
url(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
])),
# Anything else # Anything else
url(r'^.*$', StockList.as_view(), name='api-stock-list'), url(r'^.*$', StockList.as_view(), name='api-stock-list'),

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.5 on 2021-11-04 12:40
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0074_partcategorystar'),
('stock', '0066_stockitem_scheduled_for_deletion'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='part',
field=models.ForeignKey(help_text='Base part', limit_choices_to={'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.part', verbose_name='Base Part'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.5 on 2021-11-09 23:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0067_alter_stockitem_part'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='serial_int',
field=models.IntegerField(default=0),
),
]

View File

@ -0,0 +1,54 @@
# Generated by Django 3.2.5 on 2021-11-09 23:47
import re
from django.db import migrations
def update_serials(apps, schema_editor):
"""
Rebuild the integer serial number field for existing StockItem objects
"""
StockItem = apps.get_model('stock', 'stockitem')
for item in StockItem.objects.all():
if item.serial is None:
# Skip items without existing serial numbers
continue
serial = 0
result = re.match(r"^(\d+)", str(item.serial))
if result and len(result.groups()) == 1:
try:
serial = int(result.groups()[0])
except:
serial = 0
item.serial_int = serial
item.save()
def nupdate_serials(apps, schema_editor):
"""
Provided only for reverse migration compatibility
"""
pass
class Migration(migrations.Migration):
dependencies = [
('stock', '0068_stockitem_serial_int'),
]
operations = [
migrations.RunPython(
update_serials,
reverse_code=nupdate_serials,
)
]

View File

@ -7,6 +7,7 @@ Stock database model definitions
from __future__ import unicode_literals from __future__ import unicode_literals
import os import os
import re
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
@ -17,7 +18,7 @@ from django.db.models import Sum, Q
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models.signals import pre_delete from django.db.models.signals import pre_delete, post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
@ -27,7 +28,9 @@ from mptt.managers import TreeManager
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta from datetime import datetime, timedelta
from InvenTree import helpers from InvenTree import helpers
import InvenTree.tasks
import common.models import common.models
import report.models import report.models
@ -221,6 +224,32 @@ class StockItem(MPTTModel):
self.scheduled_for_deletion = True self.scheduled_for_deletion = True
self.save() self.save()
def update_serial_number(self):
"""
Update the 'serial_int' field, to be an integer representation of the serial number.
This is used for efficient numerical sorting
"""
serial = getattr(self, 'serial', '')
# Default value if we cannot convert to an integer
serial_int = 0
if serial is not None:
serial = str(serial)
# Look at the start of the string - can it be "integerized"?
result = re.match(r'^(\d+)', serial)
if result and len(result.groups()) == 1:
try:
serial_int = int(result.groups()[0])
except:
serial_int = 0
self.serial_int = serial_int
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" """
Save this StockItem to the database. Performs a number of checks: Save this StockItem to the database. Performs a number of checks:
@ -232,6 +261,8 @@ class StockItem(MPTTModel):
self.validate_unique() self.validate_unique()
self.clean() self.clean()
self.update_serial_number()
user = kwargs.pop('user', None) user = kwargs.pop('user', None)
# If 'add_note = False' specified, then no tracking note will be added for item creation # If 'add_note = False' specified, then no tracking note will be added for item creation
@ -454,7 +485,6 @@ class StockItem(MPTTModel):
verbose_name=_('Base Part'), verbose_name=_('Base Part'),
related_name='stock_items', help_text=_('Base part'), related_name='stock_items', help_text=_('Base part'),
limit_choices_to={ limit_choices_to={
'active': True,
'virtual': False 'virtual': False
}) })
@ -503,6 +533,8 @@ class StockItem(MPTTModel):
help_text=_('Serial number for this item') help_text=_('Serial number for this item')
) )
serial_int = models.IntegerField(default=0)
link = InvenTreeURLField( link = InvenTreeURLField(
verbose_name=_('External Link'), verbose_name=_('External Link'),
max_length=125, blank=True, max_length=125, blank=True,
@ -1651,6 +1683,26 @@ def before_delete_stock_item(sender, instance, using, **kwargs):
child.save() child.save()
@receiver(post_delete, sender=StockItem, dispatch_uid='stock_item_post_delete_log')
def after_delete_stock_item(sender, instance: StockItem, **kwargs):
"""
Function to be executed after a StockItem object is deleted
"""
# Run this check in the background
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part)
@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log')
def after_save_stock_item(sender, instance: StockItem, **kwargs):
"""
Hook function to be executed after StockItem object is saved/updated
"""
# Run this check in the background
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part)
class StockItemAttachment(InvenTreeAttachment): class StockItemAttachment(InvenTreeAttachment):
""" """
Model for storing file attachments against a StockItem object. Model for storing file attachments against a StockItem object.

View File

@ -9,6 +9,7 @@ from decimal import Decimal
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.db import transaction from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value from django.db.models import Case, When, Value
@ -27,14 +28,15 @@ from .models import StockItemTestResult
import common.models import common.models
from common.settings import currency_code_default, currency_code_mappings from common.settings import currency_code_default, currency_code_mappings
from company.serializers import SupplierPartSerializer from company.serializers import SupplierPartSerializer
import InvenTree.helpers
import InvenTree.serializers
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer, InvenTreeMoneySerializer
from InvenTree.serializers import InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField
class LocationBriefSerializer(InvenTreeModelSerializer): class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" """
Provides a brief serializer for a StockLocation object Provides a brief serializer for a StockLocation object
""" """
@ -48,7 +50,7 @@ class LocationBriefSerializer(InvenTreeModelSerializer):
] ]
class StockItemSerializerBrief(InvenTreeModelSerializer): class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
""" Brief serializers for a StockItem """ """ Brief serializers for a StockItem """
location_name = serializers.CharField(source='location', read_only=True) location_name = serializers.CharField(source='location', read_only=True)
@ -58,19 +60,19 @@ class StockItemSerializerBrief(InvenTreeModelSerializer):
class Meta: class Meta:
model = StockItem model = StockItem
fields = [ fields = [
'pk',
'uid',
'part', 'part',
'part_name', 'part_name',
'supplier_part', 'pk',
'location', 'location',
'location_name', 'location_name',
'quantity', 'quantity',
'serial', 'serial',
'supplier_part',
'uid',
] ]
class StockItemSerializer(InvenTreeModelSerializer): class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Serializer for a StockItem: """ Serializer for a StockItem:
- Includes serialization for the linked part - Includes serialization for the linked part
@ -134,7 +136,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True, required=False) tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True, required=False)
quantity = serializers.FloatField() # quantity = serializers.FloatField()
allocated = serializers.FloatField(source='allocation_count', required=False) allocated = serializers.FloatField(source='allocation_count', required=False)
@ -142,19 +144,22 @@ class StockItemSerializer(InvenTreeModelSerializer):
stale = serializers.BooleanField(required=False, read_only=True) stale = serializers.BooleanField(required=False, read_only=True)
serial = serializers.CharField(required=False) # serial = serializers.CharField(required=False)
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False) required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
purchase_price = InvenTreeMoneySerializer( purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
label=_('Purchase Price'), label=_('Purchase Price'),
allow_null=True max_digits=19, decimal_places=4,
allow_null=True,
help_text=_('Purchase price of this stock item'),
) )
purchase_price_currency = serializers.ChoiceField( purchase_price_currency = serializers.ChoiceField(
choices=currency_code_mappings(), choices=currency_code_mappings(),
default=currency_code_default, default=currency_code_default,
label=_('Currency'), label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
) )
purchase_price_string = serializers.SerializerMethodField() purchase_price_string = serializers.SerializerMethodField()
@ -196,6 +201,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'belongs_to', 'belongs_to',
'build', 'build',
'customer', 'customer',
'delete_on_deplete',
'expired', 'expired',
'expiry_date', 'expiry_date',
'in_stock', 'in_stock',
@ -204,6 +210,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'location', 'location',
'location_detail', 'location_detail',
'notes', 'notes',
'owner',
'packaging', 'packaging',
'part', 'part',
'part_detail', 'part_detail',
@ -242,14 +249,130 @@ class StockItemSerializer(InvenTreeModelSerializer):
] ]
class StockQuantitySerializer(InvenTreeModelSerializer): class SerializeStockItemSerializer(serializers.Serializer):
"""
A DRF serializer for "serializing" a StockItem.
(Sorry for the confusing naming...)
Here, "serializing" means splitting out a single StockItem,
into multiple single-quantity items with an assigned serial number
Note: The base StockItem object is provided to the serializer context
"""
class Meta: class Meta:
model = StockItem fields = [
fields = ('quantity',) 'quantity',
'serial_numbers',
'destination',
'notes',
]
quantity = serializers.IntegerField(
min_value=0,
required=True,
label=_('Quantity'),
help_text=_('Enter number of stock items to serialize'),
)
def validate_quantity(self, quantity):
"""
Validate that the quantity value is correct
"""
item = self.context['item']
if quantity < 0:
raise ValidationError(_("Quantity must be greater than zero"))
if quantity > item.quantity:
q = item.quantity
raise ValidationError(_(f"Quantity must not exceed available stock quantity ({q})"))
return quantity
serial_numbers = serializers.CharField(
label=_('Serial Numbers'),
help_text=_('Enter serial numbers for new items'),
allow_blank=False,
required=True,
)
destination = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Destination stock location'),
)
notes = serializers.CharField(
required=False,
allow_blank=True,
label=_("Notes"),
help_text=_("Optional note field")
)
def validate(self, data):
"""
Check that the supplied serial numbers are valid
"""
data = super().validate(data)
item = self.context['item']
if not item.part.trackable:
raise ValidationError(_("Serial numbers cannot be assigned to this part"))
# Ensure the serial numbers are valid!
quantity = data['quantity']
serial_numbers = data['serial_numbers']
try:
serials = InvenTree.helpers.extract_serial_numbers(serial_numbers, quantity)
except DjangoValidationError as e:
raise ValidationError({
'serial_numbers': e.messages,
})
existing = item.part.find_conflicting_serial_numbers(serials)
if len(existing) > 0:
exists = ','.join([str(x) for x in existing])
error = _('Serial numbers already exist') + ": " + exists
raise ValidationError({
'serial_numbers': error,
})
return data
def save(self):
item = self.context['item']
request = self.context['request']
user = request.user
data = self.validated_data
serials = InvenTree.helpers.extract_serial_numbers(
data['serial_numbers'],
data['quantity'],
)
item.serializeStock(
data['quantity'],
serials,
user,
notes=data.get('notes', ''),
location=data['destination'],
)
class LocationSerializer(InvenTreeModelSerializer): class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Detailed information about a stock location """ Detailed information about a stock location
""" """
@ -273,7 +396,7 @@ class LocationSerializer(InvenTreeModelSerializer):
] ]
class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
""" Serializer for StockItemAttachment model """ """ Serializer for StockItemAttachment model """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -284,9 +407,9 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
if user_detail is not True: if user_detail is not True:
self.fields.pop('user_detail') self.fields.pop('user_detail')
user_detail = UserSerializerBrief(source='user', read_only=True) user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True)
attachment = InvenTreeAttachmentSerializerField(required=True) attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True)
# TODO: Record the uploading user when creating or updating an attachment! # TODO: Record the uploading user when creating or updating an attachment!
@ -311,14 +434,14 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
] ]
class StockItemTestResultSerializer(InvenTreeModelSerializer): class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Serializer for the StockItemTestResult model """ """ Serializer for the StockItemTestResult model """
user_detail = UserSerializerBrief(source='user', read_only=True) user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True)
key = serializers.CharField(read_only=True) key = serializers.CharField(read_only=True)
attachment = InvenTreeAttachmentSerializerField(required=False) attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
user_detail = kwargs.pop('user_detail', False) user_detail = kwargs.pop('user_detail', False)
@ -352,7 +475,7 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer):
] ]
class StockTrackingSerializer(InvenTreeModelSerializer): class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Serializer for StockItemTracking model """ """ Serializer for StockItemTracking model """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -372,7 +495,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
user_detail = UserSerializerBrief(source='user', many=False, read_only=True) user_detail = InvenTree.serializers.UserSerializerBrief(source='user', many=False, read_only=True)
deltas = serializers.JSONField(read_only=True) deltas = serializers.JSONField(read_only=True)

View File

@ -14,7 +14,7 @@
<div class='panel panel-hidden' id='panel-history'> <div class='panel panel-hidden' id='panel-history'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Stock Tracking Information" %}</h4> <h4>{% trans "Stock Tracking Information" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -60,7 +60,7 @@
<div class='panel panel-hidden' id='panel-test-data'> <div class='panel panel-hidden' id='panel-test-data'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Test Data" %}</h4> <h4>{% trans "Test Data" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -80,12 +80,8 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='test-button-toolbar'> <div id='test-button-toolbar'>
<div class='button-toolbar container-fluid' style="float: right;"> <div class='btn-group' role='group'>
<div class='btn-group' role='group'> {% include "filter_list.html" with id="stocktests" %}
</div>
<div class='filter-list' id='filter-list-stocktests'>
<!-- Empty div -->
</div>
</div> </div>
</div> </div>
@ -95,7 +91,7 @@
<div class='panel panel-hidden' id='panel-attachments'> <div class='panel panel-hidden' id='panel-attachments'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4> <h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -133,7 +129,7 @@
<div class='panel panel-hidden' id='panel-installed-items'> <div class='panel panel-hidden' id='panel-installed-items'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-row'> <div class='d-flex flex-wrap'>
<h4>{% trans "Installed Stock Items" %}</h4> <h4>{% trans "Installed Stock Items" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>

View File

@ -53,6 +53,12 @@
</div> </div>
<!-- Stock adjustment menu --> <!-- Stock adjustment menu -->
<!-- Check permissions and owner --> <!-- Check permissions and owner -->
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
{% if owner_control.value == "True" %}
{% authorized_owners item.owner as owners %}
{% endif %}
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}
{% if roles.stock.change and not item.is_building %} {% if roles.stock.change and not item.is_building %}
<div class='btn-group'> <div class='btn-group'>
@ -393,7 +399,7 @@
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Last Stocktake" %}</td> <td>{% trans "Last Stocktake" %}</td>
{% if item.stocktake_date %} {% if item.stocktake_date %}
<td>{{ item.stocktake_date }} <span class='badge'>{{ item.stocktake_user }}</span></td> <td>{{ item.stocktake_date }} <span class='badge badge-right rounded-pill bg-dark'>{{ item.stocktake_user }}</span></td>
{% else %} {% else %}
<td><em>{% trans "No stocktake performed" %}</em></td> <td><em>{% trans "No stocktake performed" %}</em></td>
{% endif %} {% endif %}
@ -410,20 +416,33 @@
<td>{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}</td> <td>{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if item.owner %}
<tr>
<td><span class='fas fa-users'></span></td>
<td>{% trans "Owner" %}</td>
<td>{{ item.owner }}</td>
</tr>
{% endif %}
</table> </table>
{% endblock %} {% endblock details_right %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$("#stock-serialize").click(function() { $("#stock-serialize").click(function() {
launchModalForm(
"{% url 'stock-item-serialize' item.id %}", serializeStockItem({{ item.pk }}, {
{ reload: true,
reload: true, data: {
quantity: {{ item.quantity }},
{% if item.location %}
destination: {{ item.location.pk }},
{% elif item.part.default_location %}
destination: {{ item.part.default_location.pk }},
{% endif %}
} }
); });
}); });
$('#stock-install-in').click(function() { $('#stock-install-in').click(function() {
@ -463,22 +482,16 @@ $("#print-label").click(function() {
{% if roles.stock.change %} {% if roles.stock.change %}
$("#stock-duplicate").click(function() { $("#stock-duplicate").click(function() {
createNewStockItem({ // Duplicate a stock item
duplicateStockItem({{ item.pk }}, {
follow: true, follow: true,
data: {
copy: {{ item.id }},
}
}); });
}); });
$("#stock-edit").click(function () { $('#stock-edit').click(function() {
launchModalForm( editStockItem({{ item.pk }}, {
"{% url 'stock-item-edit' item.id %}", reload: true,
{ });
reload: true,
submit_text: '{% trans "Save" %}',
}
);
}); });
$('#stock-edit-status').click(function () { $('#stock-edit-status').click(function () {

View File

@ -140,7 +140,15 @@
<div class='panel panel-hidden' id='panel-stock'> <div class='panel panel-hidden' id='panel-stock'>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Stock Items" %}</h4> <div class='d-flex flex-wrap'>
<h4>{% trans "Stock Items" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='item-create' title='{% trans "Create new stock item" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
</button>
</div>
</div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% include "stock_table.html" %} {% include "stock_table.html" %}
@ -163,9 +171,7 @@
<li><a class='dropdown-item' href='#' id='multi-location-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-location-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
</ul> </ul>
</div> </div>
<div class='filter-list' id='filter-list-location'> {% include "filter_list.html" with id="location" %}
<!-- An empty div in which the filter list will be constructed -->
</div>
</div> </div>
</div> </div>
@ -185,7 +191,8 @@
{% else %} {% else %}
parent: 'null', parent: 'null',
{% endif %} {% endif %}
} },
allowTreeView: true,
}); });
linkButtonsToSelection( linkButtonsToSelection(
@ -224,33 +231,21 @@
}); });
$('#location-create').click(function () { $('#location-create').click(function () {
launchModalForm("{% url 'stock-location-create' %}",
{ createStockLocation({
data: { {% if location %}
{% if location %} parent: {{ location.pk }},
location: {{ location.id }} {% endif %}
{% endif %} follow: true,
}, });
follow: true,
secondary: [
{
field: 'parent',
label: '{% trans "New Location" %}',
title: '{% trans "Create new location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
return false;
}); });
{% if location %} {% if location %}
$('#location-edit').click(function() { $('#location-edit').click(function() {
launchModalForm("{% url 'stock-location-edit' location.id %}", editStockLocation({{ location.id }}, {
{ reload: true,
reload: true });
});
return false;
}); });
$('#location-delete').click(function() { $('#location-delete').click(function() {
@ -313,12 +308,11 @@
$('#item-create').click(function () { $('#item-create').click(function () {
createNewStockItem({ createNewStockItem({
follow: true,
data: { data: {
{% if location %} {% if location %}
location: {{ location.id }} location: {{ location.id }}
{% endif %} {% endif %}
} },
}); });
}); });

View File

@ -36,7 +36,7 @@ If this location is deleted, these items will be moved to the top level 'Stock'
<ul class='list-group'> <ul class='list-group'>
{% for item in location.stock_items.all %} {% for item in location.stock_items.all %}
<li class='list-group-item'><strong>{{ item.part.full_name }}</strong> - <em>{{ item.part.description }}</em><span class='badge'>{% decimal item.quantity %}</span></li> <li class='list-group-item'><strong>{{ item.part.full_name }}</strong> - <em>{{ item.part.description }}</em><span class='badge badge-right rounded-pill bg-dark'>{% decimal item.quantity %}</span></li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}

View File

@ -364,24 +364,22 @@ class StockItemTest(StockAPITestCase):
'part': 1, 'part': 1,
'location': 1, 'location': 1,
}, },
expected_code=201, expected_code=400
) )
# Item should have been created with default quantity self.assertIn('Quantity is required', str(response.data))
self.assertEqual(response.data['quantity'], 1)
# POST with quantity and part and location # POST with quantity and part and location
response = self.client.post( response = self.post(
self.list_url, self.list_url,
data={ data={
'part': 1, 'part': 1,
'location': 1, 'location': 1,
'quantity': 10, 'quantity': 10,
} },
expected_code=201
) )
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_default_expiry(self): def test_default_expiry(self):
""" """
Test that the "default_expiry" functionality works via the API. Test that the "default_expiry" functionality works via the API.

View File

@ -7,11 +7,6 @@ from django.contrib.auth.models import Group
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
import json
from datetime import datetime, timedelta
from InvenTree.status_codes import StockStatus
class StockViewTestCase(TestCase): class StockViewTestCase(TestCase):
@ -63,149 +58,6 @@ class StockListTest(StockViewTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
class StockLocationTest(StockViewTestCase):
""" Tests for StockLocation views """
def test_location_edit(self):
response = self.client.get(reverse('stock-location-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_qr_code(self):
# Request the StockLocation QR view
response = self.client.get(reverse('stock-location-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Test for an invalid StockLocation
response = self.client.get(reverse('stock-location-qr', args=(999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_create(self):
# Test StockLocation creation view
response = self.client.get(reverse('stock-location-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Create with a parent
response = self.client.get(reverse('stock-location-create'), {'location': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Create with an invalid parent
response = self.client.get(reverse('stock-location-create'), {'location': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
class StockItemTest(StockViewTestCase):
"""" Tests for StockItem views """
def test_qr_code(self):
# QR code for a valid item
response = self.client.get(reverse('stock-item-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# QR code for an invalid item
response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
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')
self.assertEqual(response.status_code, 200)
# Test with a non-purchaseable part
response = self.client.get(reverse('stock-item-edit', args=(100,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_create_item(self):
"""
Test creation of StockItem
"""
url = reverse('stock-item-create')
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
response = self.client.get(url, {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Copy from a valid item, valid location
response = self.client.get(url, {'location': 1, 'copy': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Copy from an invalid item, invalid location
response = self.client.get(url, {'location': 999, 'copy': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_create_stock_with_expiry(self):
"""
Test creation of stock item of a part with an expiry date.
The initial value for the "expiry_date" field should be pre-filled,
and should be in the future!
"""
# First, ensure that the expiry date feature is enabled!
InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
url = reverse('stock-item-create')
response = self.client.get(url, {'part': 25}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# We are expecting 10 days in the future
expiry = datetime.now().date() + timedelta(10)
expected = f'name=\\\\"expiry_date\\\\" value=\\\\"{expiry.isoformat()}\\\\"'
self.assertIn(expected, str(response.content))
# Now check with a part which does *not* have a default expiry period
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
expected = 'name=\\\\"expiry_date\\\\" placeholder=\\\\"\\\\"'
self.assertIn(expected, str(response.content))
def test_serialize_item(self):
# Test the serialization view
url = reverse('stock-item-serialize', args=(100,))
# GET the form
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data_valid = {
'quantity': 5,
'serial_numbers': '1-5',
'destination': 4,
'notes': 'Serializing stock test'
}
data_invalid = {
'quantity': 4,
'serial_numbers': 'dd-23-adf',
'destination': 'blorg'
}
# POST
response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['form_valid'])
# Try again to serialize with the same numbers
response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
# POST with invalid data
response = self.client.post(url, data_invalid, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
class StockOwnershipTest(StockViewTestCase): class StockOwnershipTest(StockViewTestCase):
""" Tests for stock ownership views """ """ Tests for stock ownership views """
@ -248,52 +100,39 @@ class StockOwnershipTest(StockViewTestCase):
InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user) InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user)
self.assertEqual(True, InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')) self.assertEqual(True, InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL'))
"""
TODO: Refactor this following test to use the new API form
def test_owner_control(self): def test_owner_control(self):
# Test stock location and item ownership # Test stock location and item ownership
from .models import StockLocation, StockItem from .models import StockLocation
from users.models import Owner from users.models import Owner
user_group = self.user.groups.all()[0]
user_group_owner = Owner.get_owner(user_group)
new_user_group = self.new_user.groups.all()[0] new_user_group = self.new_user.groups.all()[0]
new_user_group_owner = Owner.get_owner(new_user_group) new_user_group_owner = Owner.get_owner(new_user_group)
user_as_owner = Owner.get_owner(self.user) user_as_owner = Owner.get_owner(self.user)
new_user_as_owner = Owner.get_owner(self.new_user) new_user_as_owner = Owner.get_owner(self.new_user)
test_location_id = 4
test_item_id = 11
# Enable ownership control # Enable ownership control
self.enable_ownership() self.enable_ownership()
# Set ownership on existing location test_location_id = 4
response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)), test_item_id = 11
{'name': 'Office', 'owner': user_group_owner.pk},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
# Set ownership on existing item (and change location) # Set ownership on existing item (and change location)
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
{'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk}, {'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk},
HTTP_X_REQUESTED_WITH='XMLHttpRequest') HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200) self.assertContains(response, '"form_valid": true', status_code=200)
# Logout # Logout
self.client.logout() self.client.logout()
# Login with new user # Login with new user
self.client.login(username='john', password='custom123') self.client.login(username='john', password='custom123')
# Test location edit # TODO: Refactor this following test to use the new API form
response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)),
{'name': 'Office', 'owner': new_user_group_owner.pk},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Make sure the location's owner is unchanged
location = StockLocation.objects.get(pk=test_location_id)
self.assertEqual(location.owner, user_group_owner)
# Test item edit # Test item edit
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
{'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk}, {'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk},
@ -310,38 +149,6 @@ class StockOwnershipTest(StockViewTestCase):
'owner': new_user_group_owner.pk, 'owner': new_user_group_owner.pk,
} }
# Create new parent location
response = self.client.post(reverse('stock-location-create'),
parent_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
# Retrieve created location
parent_location = StockLocation.objects.get(name=parent_location['name'])
# Create new child location
new_location = {
'name': 'Upper Left Drawer',
'description': 'John\'s desk - Upper left drawer',
}
# Try to create new location with neither parent or owner
response = self.client.post(reverse('stock-location-create'),
new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": false', status_code=200)
# Try to create new location with invalid owner
new_location['parent'] = parent_location.id
new_location['owner'] = user_group_owner.pk
response = self.client.post(reverse('stock-location-create'),
new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": false', status_code=200)
# Try to create new location with valid owner
new_location['owner'] = new_user_group_owner.pk
response = self.client.post(reverse('stock-location-create'),
new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
# Retrieve created location # Retrieve created location
location_created = StockLocation.objects.get(name=new_location['name']) location_created = StockLocation.objects.get(name=new_location['name'])
@ -372,16 +179,4 @@ class StockOwnershipTest(StockViewTestCase):
# Logout # Logout
self.client.logout() self.client.logout()
"""
# Login with admin
self.client.login(username='username', password='password')
# Switch owner of location
response = self.client.post(reverse('stock-location-edit', args=(location_created.pk,)),
{'name': new_location['name'], 'owner': user_group_owner.pk},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
# Check that owner was updated for item in this location
stock_item = StockItem.objects.all().last()
self.assertEqual(stock_item.owner, user_group_owner)

View File

@ -8,10 +8,7 @@ from stock import views
location_urls = [ location_urls = [
url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
url(r'^(?P<pk>\d+)/', include([ url(r'^(?P<pk>\d+)/', include([
url(r'^edit/?', views.StockLocationEdit.as_view(), name='stock-location-edit'),
url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'), url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'),
url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'), url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'),
@ -22,9 +19,7 @@ location_urls = [
] ]
stock_item_detail_urls = [ stock_item_detail_urls = [
url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'),
url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
@ -50,8 +45,6 @@ stock_urls = [
# Stock location # Stock location
url(r'^location/', include(location_urls)), url(r'^location/', include(location_urls)),
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
url(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'), url(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'),
url(r'^track/', include(stock_tracking_urls)), url(r'^track/', include(stock_tracking_urls)),

View File

@ -149,6 +149,10 @@ class StockLocationEdit(AjaxUpdateView):
""" """
View for editing details of a StockLocation. View for editing details of a StockLocation.
This view is used with the EditStockLocationForm to deliver a modal form to the web view This view is used with the EditStockLocationForm to deliver a modal form to the web view
TODO: Remove this code as location editing has been migrated to the API forms
- Have to still validate that all form functionality (as below) as been ported
""" """
model = StockLocation model = StockLocation
@ -556,9 +560,8 @@ class StockItemInstall(AjaxUpdateView):
# Filter for parts to install in this item # Filter for parts to install in this item
if self.install_item: if self.install_item:
# Get parts used in this part's BOM # Get all parts which can be installed into this part
bom_items = self.part.get_bom_items() allowed_parts = self.part.get_installed_part_options()
allowed_parts = [item.sub_part for item in bom_items]
# Filter # Filter
items = items.filter(part__in=allowed_parts) items = items.filter(part__in=allowed_parts)
@ -927,6 +930,10 @@ class StockLocationCreate(AjaxCreateView):
""" """
View for creating a new StockLocation View for creating a new StockLocation
A parent location (another StockLocation object) can be passed as a query parameter A parent location (another StockLocation object) can be passed as a query parameter
TODO: Remove this class entirely, as it has been migrated to the API forms
- Still need to check that all the functionality (as below) has been implemented
""" """
model = StockLocation model = StockLocation
@ -1019,89 +1026,6 @@ class StockLocationCreate(AjaxCreateView):
pass pass
class StockItemSerialize(AjaxUpdateView):
""" View for manually serializing a StockItem """
model = StockItem
ajax_template_name = 'stock/item_serialize.html'
ajax_form_title = _('Serialize Stock')
form_class = StockForms.SerializeStockForm
def get_form(self):
context = self.get_form_kwargs()
# Pass the StockItem object through to the form
context['item'] = self.get_object()
form = StockForms.SerializeStockForm(**context)
return form
def get_initial(self):
initials = super().get_initial().copy()
item = self.get_object()
initials['quantity'] = item.quantity
initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
if item.location is not None:
initials['destination'] = item.location.pk
return initials
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
form = self.get_form()
item = self.get_object()
quantity = request.POST.get('quantity', 0)
serials = request.POST.get('serial_numbers', '')
dest_id = request.POST.get('destination', None)
notes = request.POST.get('note', '')
user = request.user
valid = True
try:
destination = StockLocation.objects.get(pk=dest_id)
except (ValueError, StockLocation.DoesNotExist):
destination = None
try:
numbers = extract_serial_numbers(serials, quantity)
except ValidationError as e:
form.add_error('serial_numbers', e.messages)
valid = False
numbers = []
if valid:
try:
item.serializeStock(quantity, numbers, user, notes=notes, location=destination)
except ValidationError as e:
messages = e.message_dict
for k in messages.keys():
if k in ['quantity', 'destination', 'serial_numbers']:
form.add_error(k, messages[k])
else:
form.add_error(None, messages[k])
valid = False
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data=data)
class StockItemCreate(AjaxCreateView): class StockItemCreate(AjaxCreateView):
""" """
View for creating a new StockItem View for creating a new StockItem

View File

@ -7,6 +7,8 @@
{% inventree_title %} | {% trans "Index" %} {% inventree_title %} | {% trans "Index" %}
{% endblock %} {% endblock %}
{% block breadcrumb_list %}
{% endblock %}
{% block sidebar %} {% block sidebar %}
<!-- Sidebar data is filled dynamically for the index page--> <!-- Sidebar data is filled dynamically for the index page-->
@ -74,6 +76,7 @@ function addHeaderAction(label, title, icon, options) {
} }
{% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %} {% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %}
{% settings_value 'HOMEPAGE_CATEGORY_STARRED' user=request.user as setting_category_starred %}
{% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %} {% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %}
{% settings_value 'HOMEPAGE_BOM_VALIDATION' user=request.user as setting_bom_validation %} {% settings_value 'HOMEPAGE_BOM_VALIDATION' user=request.user as setting_bom_validation %}
{% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %} {% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %}
@ -82,15 +85,25 @@ function addHeaderAction(label, title, icon, options) {
addHeaderTitle('{% trans "Parts" %}'); addHeaderTitle('{% trans "Parts" %}');
{% if setting_part_starred %} {% if setting_part_starred %}
addHeaderAction('starred-parts', '{% trans "Starred Parts" %}', 'fa-star'); addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bell');
loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", {
params: { params: {
"starred": true, starred: true,
}, },
name: 'starred_parts', name: 'starred_parts',
}); });
{% endif %} {% endif %}
{% if setting_category_starred %}
addHeaderAction('starred-categories', '{% trans "Subscribed Categories" %}', 'fa-bell');
loadPartCategoryTable($('#table-starred-categories'), {
params: {
starred: true,
},
name: 'starred_categories'
});
{% endif %}
{% if setting_part_latest %} {% if setting_part_latest %}
addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper'); addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper');
loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
@ -126,8 +139,7 @@ loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", {
{% to_list setting_stock_recent setting_stock_low setting_stock_depleted setting_stock_needed as settings_list_stock %} {% to_list setting_stock_recent setting_stock_low setting_stock_depleted setting_stock_needed as settings_list_stock %}
{% endif %} {% endif %}
{% if roles.stock.view and True in settings_list_stock %} {% if roles.stock.view %}
addHeaderTitle('{% trans "Stock" %}');
{% if setting_stock_recent %} {% if setting_stock_recent %}
addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock'); addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock');
@ -143,7 +155,7 @@ loadStockTable($('#table-recently-updated-stock'), {
{% endif %} {% endif %}
{% if setting_stock_low %} {% if setting_stock_low %}
addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart'); addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-flag');
loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", {
params: { params: {
low_stock: true, low_stock: true,

View File

@ -8,6 +8,9 @@
{% inventree_title %} | {% trans "Search Results" %} {% inventree_title %} | {% trans "Search Results" %}
{% endblock %} {% endblock %}
{% block breadcrumb_list %}
{% endblock %}
{% block content %} {% block content %}
<div class='panel panel-inventree'> <div class='panel panel-inventree'>

View File

@ -7,6 +7,12 @@
{% trans "Category Settings" %} {% trans "Category Settings" %}
{% endblock %} {% endblock %}
{% block actions %}
<button class='btn btn-success' id='new-cat-param' disabled=''>
<div class='fas fa-plus-circle'></div> {% trans "New Parameter" %}
</button>
{% endblock %}
{% block content %} {% block content %}
<div class='row'> <div class='row'>
@ -21,12 +27,6 @@
</form> </form>
</div> </div>
<div id='cat-param-buttons'>
<button class='btn btn-success' id='new-cat-param' disabled=''>
<div class='fas fa-plus-circle'></div> {% trans "New Parameter" %}
</button>
</div>
<table class='table table-striped table-condensed' id='cat-param-table' data-toolbar='#cat-param-buttons'> <table class='table table-striped table-condensed' id='cat-param-table' data-toolbar='#cat-param-buttons'>
</table> </table>

View File

@ -13,29 +13,31 @@
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %}
</tbody>
</table>
<table class='table table-striped table-condensed'>
<tbody>
<tr> <tr>
<td></td>
<th>{% trans "Base Currency" %}</th> <th>{% trans "Base Currency" %}</th>
<th>{{ base_currency }}</th> <th>{{ base_currency }}</th>
</tr> </tr>
<tr> <tr>
<th colspan='2'>{% trans "Exchange Rates" %}</th> <td></td>
<th colspan='4'>{% trans "Exchange Rates" %}</th>
</tr> </tr>
{% for rate in rates %} {% for rate in rates %}
<tr> <tr>
<td>{{ rate.currency }}</td> <td></td>
<td>{{ rate.value }}</td> <td>{{ rate.value }}</td>
<td>{{ rate.currency }}</td>
<td></td>
<td></td>
</tr> </tr>
{% endfor %} {% endfor %}
<tr> <tr>
<th></th>
<th> <th>
{% trans "Last Update" %} {% trans "Last Update" %}
</th> </th>
<td> <td colspan="3">
{% if rates_updated %} {% if rates_updated %}
{{ rates_updated }} {{ rates_updated }}
{% else %} {% else %}
@ -44,7 +46,7 @@
<form action='{% url "settings-currencies-refresh" %}' method='post'> <form action='{% url "settings-currencies-refresh" %}' method='post'>
<div id='refresh-rates-form'> <div id='refresh-rates-form'>
{% csrf_token %} {% csrf_token %}
<button type='submit' id='update-rates' class='btn btn-outline-secondary float-right'>{% trans "Update Now" %}</button> <button type='submit' id='update-rates' class='btn btn-primary float-right'>{% trans "Update Now" %}</button>
</div> </div>
</form> </form>
</td> </td>

View File

@ -17,7 +17,7 @@
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %}
<tr> <tr>
<td>{% trans 'Signup' %}</td> <th><h5>{% trans 'Signup' %}</h5></th>
<td colspan='4'></td> <td colspan='4'></td>
</tr> </tr>
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %}

View File

@ -9,8 +9,6 @@
{% block content %} {% block content %}
<h4>{% trans "Part Options" %}</h4>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %} {% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
@ -40,12 +38,17 @@
</tbody> </tbody>
</table> </table>
<h4>{% trans "Part Import" %}</h4> <div class='panel-heading'>
<div class='d-flex flex-span'>
<button class='btn btn-success' id='import-part'> <h4>{% trans "Part Import" %}</h4>
<span class='fas fa-plus-circle'></span> {% trans "Import Part" %} {% include "spacer.html" %}
</button> <div class='btn-group' role='group'>
<button class='btn btn-success' id='import-part'>
<span class='fas fa-plus-circle'></span> {% trans "Import Part" %}
</button>
</div>
</div>
</div>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
@ -53,14 +56,16 @@
</tbody> </tbody>
</table> </table>
<div class='panel-heading'>
<span class='d-flex flex-span'>
<h4>{% trans "Part Parameter Templates" %}</h4> <h4>{% trans "Part Parameter Templates" %}</h4>
{% include "spacer.html" %}
<div id='param-buttons'> <div class='btn-group' role='group'>
<button class='btn btn-success' id='new-param'> <button class='btn btn-success' id='new-param'>
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %} <span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
</button> </button>
</div>
</span>
</div> </div>
<table class='table table-striped table-condensed' id='param-table' data-toolbar='#param-buttons'> <table class='table table-striped table-condensed' id='param-table' data-toolbar='#param-buttons'>

View File

@ -12,6 +12,7 @@
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" %} {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" %} {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %} {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %}

View File

@ -21,15 +21,13 @@
</div> </div>
{% else %} {% else %}
<div id='setting-{{ setting.pk }}'> <div id='setting-{{ setting.pk }}'>
<strong>
<span id='setting-value-{{ setting.key.upper }}' fieldname='{{ setting.key.upper }}'> <span id='setting-value-{{ setting.key.upper }}' fieldname='{{ setting.key.upper }}'>
{% if setting.value %} {% if setting.value %}
{{ setting.value }} <strong>{{ setting.value }}</strong>
{% else %} {% else %}
<em>{% trans "No value set" %}</em> <em style='color: #855;'>{% trans "No value set" %}</em>
{% endif %} {% endif %}
</span> </span>
</strong>
{{ setting.units }} {{ setting.units }}
</div> </div>
{% endif %} {% endif %}

View File

@ -4,6 +4,9 @@
{% load static %} {% load static %}
{% load inventree_extras %} {% load inventree_extras %}
{% block breadcrumb_list %}
{% endblock %}
{% block page_title %} {% block page_title %}
{% inventree_title %} | {% trans "Settings" %} {% inventree_title %} | {% trans "Settings" %}
{% endblock %} {% endblock %}
@ -50,26 +53,17 @@
$('table').find('.btn-edit-setting').click(function() { $('table').find('.btn-edit-setting').click(function() {
var setting = $(this).attr('setting'); var setting = $(this).attr('setting');
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
var url = `/settings/${pk}/edit/`;
var is_global = true;
if ($(this).attr('user')){ if ($(this).attr('user')){
url += `user/`; is_global = false;
} }
launchModalForm( editSetting(pk, {
url, global: is_global,
{ title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}',
success: function(response) { });
if (response.is_bool) {
var enabled = response.value.toLowerCase() == 'true';
$(`#setting-value-${setting}`).prop('checked', enabled);
} else {
$(`#setting-value-${setting}`).html(response.value);
}
}
}
);
}); });
$("#edit-user").on('click', function() { $("#edit-user").on('click', function() {

View File

@ -11,18 +11,18 @@
{% trans "Account Settings" %} {% trans "Account Settings" %}
{% endblock %} {% endblock %}
{% block actions %}
<div class='btn btn-primary' type='button' id='edit-user' title='{% trans "Edit User Information" %}'>
<span class='fas fa-user-cog'></span> {% trans "Edit" %}
</div>
<div class='btn btn-primary' type='button' id='edit-password' title='{% trans "Change Password" %}'>
<span class='fas fa-key'></span> {% trans "Set Password" %}
</div>
{% endblock %}
{% block content %} {% block content %}
{% mail_configured as mail_conf %} {% mail_configured as mail_conf %}
<div class='btn-group' style='float: right;'>
<div class='btn btn-primary' type='button' id='edit-user' title='{% trans "Edit User Information" %}'>
<span class='fas fa-user-cog'></span> {% trans "Edit" %}
</div>
<div class='btn btn-primary' type='button' id='edit-password' title='{% trans "Change Password" %}'>
<span class='fas fa-key'></span> {% trans "Set Password" %}
</div>
</div>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tr> <tr>
<td>{% trans "Username" %}</td> <td>{% trans "Username" %}</td>
@ -39,61 +39,81 @@
</table> </table>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Email" %}</h4> <div class='d-flex flex-span'>
<h4>{% trans "Email" %}</h4>
{% include "spacer.html" %}
</div>
</div> </div>
<div> <div class='row'>
{% if user.emailaddress_set.all %} <div class='col-sm-6'>
<p>{% trans 'The following email addresses are associated with your account:' %}</p> {% if user.emailaddress_set.all %}
<p>{% trans 'The following email addresses are associated with your account:' %}</p>
<form action="{% url 'account_email' %}" class="email_list" method="post"> <form action="{% url 'account_email' %}" class="email_list" method="post">
{% csrf_token %} {% csrf_token %}
<fieldset class="blockLabels"> <fieldset class="blockLabels">
{% for emailaddress in user.emailaddress_set.all %} {% for emailaddress in user.emailaddress_set.all %}
<div class="ctrlHolder"> <div>
<label for="email_radio_{{forloop.counter}}" class="{% if emailaddress.primary %}primary_email{%endif%}"> <div class="ctrlHolder">
<label for="email_radio_{{forloop.counter}}" class="{% if emailaddress.primary %}primary_email{%endif%}">
<input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked"{%endif %} value="{{emailaddress.email}}"/> <input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked"{%endif %} value="{{emailaddress.email}}"/>
{{ emailaddress.email }} {% if emailaddress.primary %}
{% if emailaddress.verified %} <b>{{ emailaddress.email }}</b>
<span class="verified">{% trans "Verified" %}</span> {% else %}
{% else %} {{ emailaddress.email }}
<span class="unverified">{% trans "Unverified" %}</span> {% endif %}
{% endif %} </label>
{% if emailaddress.primary %}<span class="primary">{% trans "Primary" %}</span>{% endif %} {% if emailaddress.verified %}
</label> <span class='badge badge-right rounded-pill bg-success'>{% trans "Verified" %}</span>
{% else %}
<span class='badge badge-right rounded-pill bg-warning'>{% trans "Unverified" %}</span>
{% endif %}
{% if emailaddress.primary %}<span class='badge badge-right rounded-pill bg-primary'>{% trans "Primary" %}</span>{% endif %}
</div>
</div> </div>
{% endfor %} {% endfor %}
<div class="buttonHolder"> <div class="buttonHolder">
<button class="btn btn-primary secondaryAction" type="submit" name="action_primary" >{% trans 'Make Primary' %}</button> <button class="btn btn-primary secondaryAction" type="submit" name="action_primary" >{% trans 'Make Primary' %}</button>
<button class="btn btn-primary secondaryAction" type="submit" name="action_send" {% if not mail_conf %}disabled{% endif %}>{% trans 'Re-send Verification' %}</button> <button class="btn btn-primary secondaryAction" type="submit" name="action_send" {% if not mail_conf %}disabled{% endif %}>{% trans 'Re-send Verification' %}</button>
<button class="btn btn-primary primaryAction" type="submit" name="action_remove" >{% trans 'Remove' %}</button> <button class="btn btn-primary primaryAction" type="submit" name="action_remove" >{% trans 'Remove' %}</button>
</div> </div>
</fieldset> </fieldset>
</form> </form>
{% else %} {% else %}
<p><strong>{% trans 'Warning:'%}</strong> <p><strong>{% trans 'Warning:'%}</strong>
{% trans "You currently do not have any email address set up. You should really add an email address so you can receive notifications, reset your password, etc." %} {% trans "You currently do not have any email address set up. You should really add an email address so you can receive notifications, reset your password, etc." %}
</p> </p>
{% endif %} {% endif %}
</div>
{% if can_add_email %} <div class='col-sm-6'>
<br> {% if can_add_email %}
<h4>{% trans "Add Email Address" %}</h4> <h5>{% trans "Add Email Address" %}</h5>
<form method="post" action="{% url 'account_email' %}" class="add_email"> <form method="post" action="{% url 'account_email' %}" class="add_email">
{% csrf_token %} {% csrf_token %}
{{ add_email_form|crispy }}
<button class="btn btn-primary" name="action_add" type="submit">{% trans "Add Email" %}</button> <label for="id_email" class=" requiredField">
E-mail<span class="asteriskField">*</span>
</label>
<div id="div_id_email" class="form-group input-group mb-3">
<div class='input-group-prepend'><span class='input-group-text'>@</span></div>
<input type="email" name="email" placeholder='{% trans "Enter e-mail address" %}' class="textinput textInput form-control" required="" id="id_email">
<div class='input-group-append'>
<button class="btn btn-primary" name="action_add" type="submit">{% trans "Add Email" %}</button>
</div>
</div>
</form> </form>
{% endif %} {% endif %}
<br> </div>
</div> </div>
<div class='panel-heading'> <div class='panel-heading'>
@ -135,7 +155,9 @@
</form> </form>
{% else %} {% else %}
<p>{% trans 'You currently have no social network accounts connected to this account.' %}</p> <div class='alert alert-block alert-warning'>
{% trans "There are no social network accounts connected to your InvenTree account" %}
</div>
{% endif %} {% endif %}
<br> <br>
@ -155,26 +177,26 @@
<div class='row'> <div class='row'>
<form action='{% url "settings-appearance" %}' method='post'> <div class='col-sm-6'>
{% csrf_token %} <form action='{% url "settings-appearance" %}' method='post'>
<input name='next' type='hidden' value='{% url "settings" %}'> {% csrf_token %}
<div class="col-sm-6" style="width: 200px;"> <input name='next' type='hidden' value='{% url "settings" %}'>
<div id="div_id_themes" class="form-group"> <label for='theme' class=' requiredField'>
<div class="controls "> {% trans "Select theme" %}
<select name='theme' class='select form-control'> </label>
{% get_available_themes as themes %} <div class='form-group input-group mb-3'>
{% for theme in themes %} <select id='theme' name='theme' class='select form-control'>
<option value='{{ theme.key }}'>{{ theme.name }}</option> {% get_available_themes as themes %}
{% endfor %} {% for theme in themes %}
</select> <option value='{{ theme.key }}'>{{ theme.name }}</option>
{% endfor %}
</select>
<div class='input-group-append'>
<input type="submit" value="{% trans 'Set Theme' %}" class="btn btn-primary">
</div> </div>
</div> </div>
</div> </form>
<div class="col-sm-6" style="width: auto;"> </div>
<input type="submit" value="{% trans 'Set Theme' %}" class="btn btn btn-primary">
</div>
</form>
</div> </div>
<div class='panel-heading'> <div class='panel-heading'>
@ -186,29 +208,43 @@
<form action="{% url 'set_language' %}" method="post"> <form action="{% url 'set_language' %}" method="post">
{% csrf_token %} {% csrf_token %}
<input name="next" type="hidden" value="{% url 'settings' %}"> <input name="next" type="hidden" value="{% url 'settings' %}">
<div class="col-sm-6" style="width: 200px;"><div id="div_id_language" class="form-group"><div class="controls "> <label for='language' class=' requiredField'>
<select name="language" class="select form-control"> {% trans "Select language" %}
</label>
<div class='form-group input-group mb-3'>
<select name="language" class="select form-control w-25">
{% get_current_language as LANGUAGE_CODE %} {% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %} {% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %} {% get_language_info_list for LANGUAGES as languages %}
{% if 'alllang' in request.GET %}{% define True as ALL_LANG %}{% endif %}
{% for language in languages %} {% for language in languages %}
{% define language.code as lang_code %} {% define language.code as lang_code %}
{% define locale_stats|keyvalue:lang_code as lang_translated %} {% define locale_stats|keyvalue:lang_code as lang_translated %}
{% if lang_translated > 10 or lang_code == 'en' or lang_code == LANGUAGE_CODE %}{% define True as use_lang %}{% else %}{% define False as use_lang %}{% endif %}
{% if ALL_LANG or use_lang %}
<option value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}> <option value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ lang_code }}) {{ language.name_local }} ({{ lang_code }})
{% if lang_translated %} {% if lang_translated %}
{% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %} {% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %}
{% else %} {% else %}
{% trans 'No translations available' %} {% if lang_code == 'en' %}-{% else %}{% trans 'No translations available' %}{% endif %}
{% endif %} {% endif %}
</option> </option>
{% endif %}
{% endfor %} {% endfor %}
</select> </select>
</div></div></div> <div class='input-group-append'>
<div class="col-sm-6" style="width: auto;"> <input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary">
<input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary"> </div>
<p>{% trans "Some languages are not complete" %}
{% if ALL_LANG %}
. <a href="{% url 'settings' %}">{% trans "Show only sufficent" %}</a>
{% else %}
and hidden. <a href="?alllang">{% trans "Show them too" %}</a>
{% endif %}
</p>
</div> </div>
</form> </form>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<h4>{% trans "Help the translation efforts!" %}</h4> <h4>{% trans "Help the translation efforts!" %}</h4>

View File

@ -14,7 +14,8 @@
<div class='row'> <div class='row'>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-star' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-bell' user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_CATEGORY_STARRED" icon='fa-bell' user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" icon='fa-history' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" icon='fa-history' user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %}

View File

@ -16,6 +16,7 @@
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_SHOW_STOCK_LEVELS" user_setting=True icon='fa-boxes' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_SHOW_STOCK_LEVELS" user_setting=True icon='fa-boxes' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_HIDE_INACTIVE_PARTS" user_setting=True icon='fa-eye-slash' %}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -22,12 +22,12 @@
<a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a>{% include "clip.html" %} <a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a>{% include "clip.html" %}
{% inventree_is_development as dev %} {% inventree_is_development as dev %}
{% if dev %} {% if dev %}
<span class='badge rounded-pill bg-primary'>{% trans "Development Version" %}</span> <span class='badge badge-right rounded-pill bg-primary'>{% trans "Development Version" %}</span>
{% else %} {% else %}
{% if up_to_date %} {% if up_to_date %}
<span class='badge rounded-pill bg-success'>{% trans "Up to Date" %}</span> <span class='badge badge-right rounded-pill bg-success'>{% trans "Up to Date" %}</span>
{% else %} {% else %}
<span class='badge rounded-pill bg-info'>{% trans "Update Available" %}</span> <span class='badge badge-right rounded-pill bg-info'>{% trans "Update Available" %}</span>
{% endif %} {% endif %}
{% endif %} {% endif %}
</td> </td>

View File

@ -10,10 +10,30 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="57x57" href="{% static 'img/favicon/apple-icon-57x57.png' %}">
<link rel="apple-touch-icon" sizes="60x60" href="{% static 'img/favicon/apple-icon-60x60.png' %}">
<link rel="apple-touch-icon" sizes="72x72" href="{% static 'img/favicon/apple-icon-72x72.png' %}">
<link rel="apple-touch-icon" sizes="76x76" href="{% static 'img/favicon/apple-icon-76x76.png' %}">
<link rel="apple-touch-icon" sizes="114x114" href="{% static 'img/favicon/apple-icon-114x114.png' %}">
<link rel="apple-touch-icon" sizes="120x120" href="{% static 'img/favicon/apple-icon-120x120.png' %}">
<link rel="apple-touch-icon" sizes="144x144" href="{% static 'img/favicon/apple-icon-144x144.png' %}">
<link rel="apple-touch-icon" sizes="152x152" href="{% static 'img/favicon/apple-icon-152x152.png' %}">
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'img/favicon/apple-icon-180x180.png' %}">
<link rel="icon" type="image/png" sizes="192x192" href="{% static 'img/favicon/android-icon-192x192.png' %}">
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}">
<link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}">
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}">
<link rel="manifest" href="{% static 'img/favicon/manifest.json' %}">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}">
<meta name="theme-color" content="#ffffff">
<!-- CSS --> <!-- CSS -->
<link rel="stylesheet" href="{% static 'fontawesome/css/brands.css' %}"> <link rel="stylesheet" href="{% static 'fontawesome/css/brands.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/solid.css' %}"> <link rel="stylesheet" href="{% static 'fontawesome/css/solid.css' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap_3.3.7_css_bootstrap.min.css' %}"> <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'select2/css/select2.css' %}"> <link rel="stylesheet" href="{% static 'select2/css/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2/css/select2-bootstrap-5-theme.css' %}"> <link rel="stylesheet" href="{% static 'select2/css/select2-bootstrap-5-theme.css' %}">
<link rel="stylesheet" href="{% static 'css/inventree.css' %}"> <link rel="stylesheet" href="{% static 'css/inventree.css' %}">
@ -33,41 +53,60 @@
<!-- <!--
Background Image Attribution: https://unsplash.com/photos/Ixvv3YZkd7w Background Image Attribution: https://unsplash.com/photos/Ixvv3YZkd7w
--> -->
<div class='container-fluid'>
<div class='notification-area' id='alerts'>
<!-- Div for displayed alerts -->
</div>
</div>
<div class='main body-wrapper login-screen d-flex'>
<div class='main body-wrapper login-screen'>
<div class='login-container'> <div class='login-container'>
<div class="row"> <div class="row">
<div class='container-fluid'> <div class='container-fluid'>
<div class='clearfix content-heading login-header'> <div class='clearfix content-heading login-header d-flex flex-wrap'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/> <img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
<span><h3>{% inventree_title %}</h3></span> {% include "spacer.html" %}
<span class='float-right'><h3>{% inventree_title %}</h3></span>
</div> </div>
<hr>
<div class='container-fluid'>{% block content %}{% endblock %}</div>
</div> </div>
<div class='container-fluid'>
<hr>
{% block content %}
{% endblock %}
</div>
</div> </div>
</div> </div>
{% block extra_body %} {% block extra_body %}
{% endblock %} {% endblock %}
{% include 'notification.html' %}
</div> </div>
<!-- Scripts --> <!-- Scripts -->
<script type="text/javascript" src="{% static 'script/jquery_3.3.1_jquery.min.js' %}"></script> <script type="text/javascript" src="{% static 'script/jquery_3.3.1_jquery.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/jquery-ui/jquery-ui.min.js' %}"></script>
<script type="text/javascript" src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
<!-- general InvenTree --> <!-- general InvenTree -->
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<!-- dynamic javascript templates --> <!-- fontawesome -->
<script type='text/javascript' src="{% url 'inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script> <script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script> <script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/fontawesome.js' %}"></script> <script type='text/javascript' src="{% static 'fontawesome/js/fontawesome.js' %}"></script>
<!-- 3rd party general js -->
<script type="text/javascript" src="{% static 'fullcalendar/main.js' %}"></script>
<script type="text/javascript" src="{% static 'fullcalendar/locales-all.js' %}"></script>
<script type="text/javascript" src="{% static 'select2/js/select2.full.js' %}"></script>
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
<script type='text/javascript' src="{% static 'script/chart.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
<script type='text/javascript'> <script type='text/javascript'>
@ -75,12 +114,16 @@ $(document).ready(function () {
// notifications // notifications
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
showAlertOrCache('alert-info', '{{message}}', true); showAlertOrCache(
'{{ message }}',
true,
{
style: 'info',
}
);
{% endfor %} {% endfor %}
{% endif %} {% endif %}
showCachedAlerts();
inventreeDocReady(); inventreeDocReady();
}); });

View File

@ -32,12 +32,13 @@ for a account and sign in below:{% endblocktrans %}</p>
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" /> <input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" />
{% endif %} {% endif %}
<div class="btn-toolbar"> <hr>
<button class="btn btn-primary col-md-8" type="submit">{% trans "Sign In" %}</button> <div class="btn-group float-right" role="group">
{% if mail_conf and enable_pwd_forgot %} <button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
<a class="btn btn-primary" href="{% url 'account_reset_password' %}">{% trans "Forgot Password?" %}</a> </div>
{% endif %} {% if mail_conf and enable_pwd_forgot %}
</div> <a class="" href="{% url 'account_reset_password' %}"><small>{% trans "Forgot Password?" %}</small></a>
{% endif %}
</form> </form>
{% if enable_sso %} {% if enable_sso %}

View File

@ -14,7 +14,11 @@
{% if redirect_field_value %} {% if redirect_field_value %}
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/> <input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/>
{% endif %} {% endif %}
<button type="submit" class="btn btn-primary btn-block">{% trans 'Sign Out' %}</button> <hr>
<div class='btn-group float-right' role='group'>
<a type='button' class='btn btn-secondary' href='{% url "index" %}'><span class='fas fa-undo-alt'></span> {% trans "Back to Site" %}</a>
<button type="submit" class="btn btn-danger btn-block">{% trans 'Sign Out' %}</button>
</div>
</form> </form>

View File

@ -14,7 +14,9 @@
<form method="POST" action="{{ action_url }}"> <form method="POST" action="{{ action_url }}">
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
<input type="submit" name="action" class="btn btn-primary btn-block" value="{% trans 'change password' %}"/> <div class='btn-group float-right' role='group'>
<input type="submit" name="action" class="btn btn-success" value="{% trans 'Change password' %}"/>
</div>
</form> </form>
{% else %} {% else %}
<p>{% trans 'Your password is now changed.' %}</p> <p>{% trans 'Your password is now changed.' %}</p>

View File

@ -1,10 +1,8 @@
{% load i18n %} {% load i18n %}
<div id='attachment-buttons'> <div id='attachment-buttons'>
<div class='btn-group'> <div class='btn-group' role='group'>
<div class='filter-list' id='filter-list-related'> {% include "filter_list.html" with id="related" %}
<!-- An empty div in which the filter list will be constructed -->
</div>
</div> </div>
</div> </div>

View File

@ -4,6 +4,7 @@
{% settings_value 'BARCODE_ENABLE' as barcodes %} {% settings_value 'BARCODE_ENABLE' as barcodes %}
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %} {% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
{% settings_value "REPORT_ENABLE" as report_enabled %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -83,7 +84,14 @@
</div> </div>
</div> </div>
</div> </div>
<main class='col ps-md-2 pt-2'> <main class='col ps-md-2 pt-2 pe-2'>
{% block alerts %}
<div class='notification-area' id='alerts'>
<!-- Div for displayed alerts -->
</div>
{% endblock %}
{% block breadcrumb_list %} {% block breadcrumb_list %}
<div class='container-fluid navigation'> <div class='container-fluid navigation'>
<nav aria-label='breadcrumb'> <nav aria-label='breadcrumb'>
@ -102,7 +110,6 @@
</div> </div>
{% include 'modals.html' %} {% include 'modals.html' %}
{% include 'about.html' %} {% include 'about.html' %}
{% include 'notification.html' %}
</div> </div>
<!-- Scripts --> <!-- Scripts -->
@ -135,9 +142,9 @@
<!-- general InvenTree --> <!-- general InvenTree -->
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<!-- dynamic javascript templates --> <!-- dynamic javascript templates -->
<script type='text/javascript' src="{% url 'inventree.js' %}"></script>
<script type='text/javascript' src="{% url 'calendar.js' %}"></script> <script type='text/javascript' src="{% url 'calendar.js' %}"></script>
<script type='text/javascript' src="{% url 'nav.js' %}"></script> <script type='text/javascript' src="{% url 'nav.js' %}"></script>
<script type='text/javascript' src="{% url 'settings.js' %}"></script> <script type='text/javascript' src="{% url 'settings.js' %}"></script>
@ -177,15 +184,25 @@ $(document).ready(function () {
inventreeDocReady(); inventreeDocReady();
showCachedAlerts();
{% if barcodes %} {% if barcodes %}
$('#barcode-scan').click(function() { $('#barcode-scan').click(function() {
barcodeScanDialog(); barcodeScanDialog();
}); });
{% endif %} {% endif %}
moment.locale('{{request.LANGUAGE_CODE}}'); moment.locale('{{ request.LANGUAGE_CODE }}');
// Account notifications
{% if messages %}
{% for message in messages %}
showMessage(
'{{ message }}',
{
style: 'info',
}
);
{% endfor %}
{% endif %}
}); });
</script> </script>

View File

@ -0,0 +1,39 @@
{% extends "email/email.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block title %}
{% trans "Stock is required for the following build order" %}<br>
{% blocktrans with build=build.reference part=part.full_name quantity=build.quantity %}Build order {{ build }} - building {{ quantity }} x {{ part }}{% endblocktrans %}
<br>
<p>{% trans "Click on the following link to view this build order" %}: <a href='{{ link }}'>{{ link }}</a></p>
{% endblock title %}
{% block body %}
<tr colspan='100%' style='height: 2rem; text-align: center;'>{% trans "The following parts are low on required stock" %}</tr>
<tr style="height: 3rem; border-bottom: 1px solid">
<th>{% trans "Part" %}</th>
<th>{% trans "Required Quantity" %}</th>
<th>{% trans "Available" %}</th>
</tr>
{% for line in lines %}
<tr style="height: 2.5rem; border-bottom: 1px solid">
<td style='padding-left: 1em;'>
<a href='{{ line.link }}'>{{ line.part.full_name }}</a>{% if part.description %} - <em>{{ part.description }}</em>{% endif %}
</td>
<td style="text-align: center;">
{% decimal line.required %} {% if line.part.units %}{{ line.part.units }}{% endif %}
</td>
<td style="text-align: center;">{% decimal line.available %} {% if line.part.units %}{{ line.part.units }}{% endif %}</td>
</tr>
{% endfor %}
{% endblock body %}
{% block footer_prefix %}
<p><em>{% blocktrans with part=part.name %}You are receiving this email because you are subscribed to notifications for this part {% endblocktrans %}.</em></p>
{% endblock footer_prefix %}

View File

@ -0,0 +1,43 @@
{% load i18n %}
{% load static %}
{% load inventree_extras %}
<table style='border-collapse: collapse; width: 85%; margin-left: 10%; font-size: 1rem; border: 1px solid #68686a; border-radius: 2px;'>
{% block header %}
<tr style='background: #eef3f7; height: 4rem; text-align: center;'>
<th colspan="100%" style="padding-bottom: 1rem; color: #68686a;">
{% block header_row %}
<p style='font-size: 1.25rem;'>{% block title %}<!-- email title goes here -->{% endblock %}</p>
{% block subtitle %}
<!-- email subtitle goes here -->
{% endblock %}
{% endblock %}
</th>
</tr>
{% endblock %}
{% block body %}
<tr style="height: 3rem; border-bottom: 1px solid #68686a;">
{% block body_row %}
<!-- email body goes here -->
{% endblock %}
</tr>
{% endblock %}
{% block footer %}
<tr style='background: #eef3f7; height: 2rem;'>
<td colspan="100%" style="padding-top:1rem; text-align: center">
{% block footer_prefix %}
<!-- Custom footer information goes here -->
{% endblock %}
<p><em><small>{% trans "InvenTree version" %}: {% inventree_version %} - <a href='https://inventree.readthedocs.io'>inventree.readthedocs.io</a></small></em></p>
{% block footer_suffix %}
<!-- Custom footer information goes here -->
{% endblock %}
</td>
</tr>
{% endblock %}
</table>

View File

@ -0,0 +1,32 @@
{% extends "email/email.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block title %}
{% blocktrans with part=part.name %} The available stock for {{ part }} has fallen below the configured minimum level{% endblocktrans %}
{% if link %}
<p>{% trans "Click on the following link to view this part" %}: <a href="{{ link }}">{{ link }}</a></p>
{% endif %}
{% endblock title %}
{% block body %}
<tr style="height: 3rem; border-bottom: 1px solid">
<th>{% trans "Part" %}</th>
<th>{% trans "Total Stock" %}</th>
<th>{% trans "Available" %}</th>
<th>{% trans "Minimum Quantity" %}</th>
</tr>
<tr style="height: 3rem">
<td style="text-align: center;">{{ part.full_name }}</td>
<td style="text-align: center;">{% decimal part.total_stock %}</td>
<td style="text-align: center;">{% decimal part.available_stock %}</td>
<td style="text-align: center;">{% decimal part.minimum_stock %}</td>
</tr>
{% endblock body %}
{% block footer_prefix %}
<p><em>{% blocktrans with part=part.name %}You are receiving this email because you are subscribed to notifications for this part {% endblocktrans %}.</em></p>
{% endblock footer_prefix %}

View File

@ -0,0 +1 @@
<div class='filter-list d-flex flex-row form-row' id='filter-list-{{ id }}'><!-- Empty div for table filters --></div>

View File

@ -1,6 +1,7 @@
{% load inventree_extras %} {% load inventree_extras %}
/* exported /* exported
editSetting,
user_settings, user_settings,
global_settings, global_settings,
*/ */
@ -18,3 +19,83 @@ const global_settings = {
{{ key }}: {% primitive_to_javascript value %}, {{ key }}: {% primitive_to_javascript value %},
{% endfor %} {% endfor %}
}; };
/*
* Edit a setting value
*/
function editSetting(pk, options={}) {
// Is this a global setting or a user setting?
var global = options.global || false;
var url = '';
if (global) {
url = `/api/settings/global/${pk}/`;
} else {
url = `/api/settings/user/${pk}/`;
}
// First, read the settings object from the server
inventreeGet(url, {}, {
success: function(response) {
if (response.choices && response.choices.length > 0) {
response.type = 'choice';
}
// Construct the field
var fields = {
value: {
label: response.name,
help_text: response.description,
type: response.type,
choices: response.choices,
}
};
constructChangeForm(fields, {
url: url,
method: 'PATCH',
title: options.title,
processResults: function(data, fields, opts) {
switch (data.type) {
case 'boolean':
// Convert to boolean value
data.value = data.value.toString().toLowerCase() == 'true';
break;
case 'integer':
// Convert to integer value
data.value = parseInt(data.value.toString());
break;
default:
break;
}
return data;
},
processBeforeUpload: function(data) {
// Convert value to string
data.value = data.value.toString();
return data;
},
onSuccess: function(response) {
var setting = response.key;
if (response.type == 'boolean') {
var enabled = response.value.toString().toLowerCase() == 'true';
$(`#setting-value-${setting}`).prop('checked', enabled);
} else {
$(`#setting-value-${setting}`).html(response.value);
}
}
});
},
error: function(xhr) {
showApiError(xhr, url);
}
});
}

View File

@ -2,8 +2,6 @@
{% load inventree_extras %} {% load inventree_extras %}
/* globals /* globals
renderErrorMessage,
showAlertDialog,
*/ */
/* exported /* exported
@ -63,11 +61,17 @@ function inventreeGet(url, filters={}, options={}) {
}, },
error: function(xhr, ajaxOptions, thrownError) { error: function(xhr, ajaxOptions, thrownError) {
console.error('Error on GET at ' + url); console.error('Error on GET at ' + url);
console.error(thrownError);
if (thrownError) {
console.error('Error: ' + thrownError);
}
if (options.error) { if (options.error) {
options.error({ options.error({
error: thrownError error: thrownError
}); });
} else {
showApiError(xhr, url);
} }
} }
}); });
@ -104,6 +108,8 @@ function inventreeFormDataUpload(url, data, options={}) {
if (options.error) { if (options.error) {
options.error(xhr, status, error); options.error(xhr, status, error);
} else {
showApiError(xhr, url);
} }
} }
}); });
@ -139,6 +145,8 @@ function inventreePut(url, data={}, options={}) {
} else { } else {
console.error(`Error on ${method} to '${url}' - STATUS ${xhr.status}`); console.error(`Error on ${method} to '${url}' - STATUS ${xhr.status}`);
console.error(thrownError); console.error(thrownError);
showApiError(xhr, url);
} }
}, },
complete: function(xhr, status) { complete: function(xhr, status) {
@ -162,13 +170,15 @@ function inventreeDelete(url, options={}) {
return inventreePut(url, {}, options); return inventreePut(url, {}, options);
} }
/*
function showApiError(xhr) { * Display a notification with error information
*/
function showApiError(xhr, url) {
var title = null; var title = null;
var message = null; var message = null;
switch (xhr.status) { switch (xhr.status || 0) {
// No response // No response
case 0: case 0:
title = '{% trans "No Response" %}'; title = '{% trans "No Response" %}';
@ -208,7 +218,11 @@ function showApiError(xhr) {
} }
message += '<hr>'; message += '<hr>';
message += renderErrorMessage(xhr); message += `URL: ${url}`;
showAlertDialog(title, message); showMessage(title, {
style: 'danger',
icon: 'fas fa-server icon-red',
details: message,
});
} }

View File

@ -10,7 +10,6 @@
modalSetSubmitText, modalSetSubmitText,
modalShowSubmitButton, modalShowSubmitButton,
modalSubmit, modalSubmit,
showAlertOrCache,
showQuestionDialog, showQuestionDialog,
*/ */
@ -36,7 +35,7 @@ function makeBarcodeInput(placeholderText='', hintText='') {
<label class='control-label' for='barcode'>{% trans "Barcode" %}</label> <label class='control-label' for='barcode'>{% trans "Barcode" %}</label>
<div class='controls'> <div class='controls'>
<div class='input-group'> <div class='input-group'>
<span class='input-group-addon'> <span class='input-group-text'>
<span class='fas fa-qrcode'></span> <span class='fas fa-qrcode'></span>
</span> </span>
<input id='barcode' class='textinput textInput form-control' type='text' name='barcode' placeholder='${placeholderText}'> <input id='barcode' class='textinput textInput form-control' type='text' name='barcode' placeholder='${placeholderText}'>
@ -59,7 +58,7 @@ function makeNotesField(options={}) {
<label class='control-label' for='notes'>{% trans "Notes" %}</label> <label class='control-label' for='notes'>{% trans "Notes" %}</label>
<div class='controls'> <div class='controls'>
<div class='input-group'> <div class='input-group'>
<span class='input-group-addon'> <span class='input-group-text'>
<span class='fas fa-sticky-note'></span> <span class='fas fa-sticky-note'></span>
</span> </span>
<input id='notes' class='textinput textInput form-control' type='text' name='notes' placeholder='${placeholder}'> <input id='notes' class='textinput textInput form-control' type='text' name='notes' placeholder='${placeholder}'>
@ -258,7 +257,7 @@ function barcodeDialog(title, options={}) {
$(modal).modal({ $(modal).modal({
backdrop: 'static', backdrop: 'static',
keyboard: false, keyboard: user_settings.FORMS_CLOSE_USING_ESCAPE,
}); });
if (options.preShow) { if (options.preShow) {
@ -480,10 +479,13 @@ function barcodeCheckIn(location_id) {
$(modal).modal('hide'); $(modal).modal('hide');
if (status == 'success' && 'success' in response) { if (status == 'success' && 'success' in response) {
showAlertOrCache('alert-success', response.success, true); addCachedAlert(response.success);
location.reload(); location.reload();
} else { } else {
showAlertOrCache('alert-success', '{% trans "Error transferring stock" %}', false); showMessage('{% trans "Error transferring stock" %}', {
style: 'danger',
icon: 'fas fa-times-circle',
});
} }
} }
} }
@ -604,10 +606,12 @@ function scanItemsIntoLocation(item_id_list, options={}) {
$(modal).modal('hide'); $(modal).modal('hide');
if (status == 'success' && 'success' in response) { if (status == 'success' && 'success' in response) {
showAlertOrCache('alert-success', response.success, true); addCachedAlert(response.success);
location.reload(); location.reload();
} else { } else {
showAlertOrCache('alert-danger', '{% trans "Error transferring stock" %}', false); showMessage('{% trans "Error transferring stock" %}', {
style: 'danger',
});
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More