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
about: Create a bug report to help us improve InvenTree
name: Bug
about: Create a bug report to help us improve InvenTree!
title: "[BUG] Enter bug description"
labels: bug, question
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:
<!---
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
--->
**Expected behavior**
<!---
A clear and concise description of what you expected to happen.
--->
<!---
**Screenshots**
If applicable, add screenshots to help explain your problem.
--->
**Deployment Method**
Docker
Bare Metal
- [ ] Docker
- [ ] Bare Metal
**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: |
npm install markuplint
npx markuplint InvenTree/build/templates/build/*.html
npx markuplint InvenTree/common/templates/common/*.html
npx markuplint InvenTree/company/templates/company/*.html
npx markuplint InvenTree/order/templates/order/*.html
npx markuplint InvenTree/part/templates/part/*.html

View File

@ -76,6 +76,12 @@ class InvenTreeConfig(AppConfig):
minutes=30,
)
# Delete old notification records
InvenTree.tasks.schedule_task(
'common.tasks.delete_old_notifications',
schedule_type=Schedule.DAILY,
)
def update_exchange_rates(self):
"""
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))
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():
"""
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
logger = logging.getLogger("inventree-thumbnails")
logger = logging.getLogger('inventree')
class Command(BaseCommand):

View File

@ -98,7 +98,7 @@ class InvenTreeMetadata(SimpleMetadata):
Override get_serializer_info so that we can add 'default' values
to any fields whose Meta.model specifies a default value
"""
serializer_info = super().get_serializer_info(serializer)
model_class = None
@ -108,6 +108,13 @@ class InvenTreeMetadata(SimpleMetadata):
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
for name, field in model_fields.fields.items():
@ -123,6 +130,9 @@ class InvenTreeMetadata(SimpleMetadata):
serializer_info[name]['default'] = default
elif name in model_default_values:
serializer_info[name]['default'] = model_default_values[name]
# Iterate through relations
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'):
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:
pass

View File

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

View File

@ -1,5 +1,3 @@
{% load inventree_extras %}
/* globals
ClipboardJS,
inventreeFormDataUpload,
@ -130,61 +128,79 @@ function inventreeDocReady() {
attachClipboard('.clip-btn-version', 'modal-about', 'about-copy-text');
// Add autocomplete to the search-bar
$('#search-bar').autocomplete({
source: function(request, response) {
$.ajax({
url: '/api/part/',
data: {
if ($('#search-bar').exists()) {
$('#search-bar').autocomplete({
source: function(request, response) {
var params = {
search: request.term,
limit: user_settings.SEARCH_PREVIEW_RESULTS,
offset: 0
},
success: function(data) {
offset: 0,
};
var transformed = $.map(data.results, function(el) {
return {
label: el.full_name,
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);
if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) {
// Limit to active parts
params.active = true;
}
html += '</a>';
$.ajax({
url: '/api/part/',
data: params,
success: function(data) {
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',
},
});
var transformed = $.map(data.results, function(el) {
return {
label: el.full_name,
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 = `
<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
$('.brand-icon').each(function(i, obj) {
@ -197,6 +213,9 @@ function inventreeDocReady() {
location.href = url;
});
// Display any cached alert messages
showCachedAlerts();
}
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);
$(target).show();
$(target).delay(timeout).slideUp(200, function() {
var alerts = sessionStorage.getItem('inventree-alerts');
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);
});
}
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
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.
This is different to a 'scheduled' task,
@ -108,7 +108,7 @@ def offload_task(taskname, force_sync=False, *args, **kwargs):
return
# Workers are not running: run it as synchronous task
_func()
_func(*args, **kwargs)
def heartbeat():
@ -290,7 +290,7 @@ def update_exchange_rates():
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,
to the specified recipients list.
@ -306,4 +306,5 @@ def send_email(subject, body, recipients, from_email=None):
from_email,
recipients,
fail_silently=False,
html_message=html_message
)

View File

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

View File

@ -42,8 +42,6 @@ from .views import CurrencyRefreshView
from .views import AppearanceSelectView, SettingCategorySelectView
from .views import DynamicJsView
from common.views import SettingEdit, UserSettingEdit
from .api import InfoView, NotFoundView
from .api import ActionPluginView
@ -53,7 +51,7 @@ admin.site.site_header = "InvenTree Admin"
apipatterns = [
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'^bom/', include(bom_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'^(?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
url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'),
]
# These javascript files are served "dynamically" - i.e. rendered on demand
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'^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'),

View File

@ -12,15 +12,19 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev"
# 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
v17 -> 2021-10-26
v18 -> 2021-11-11
- Adds support for multiple "Shipments" 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
- 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)
# 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

View File

@ -9,16 +9,16 @@ import decimal
import os
from datetime import datetime
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import Sum, Q
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
@ -27,16 +27,17 @@ from mptt.exceptions import InvalidMove
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
from InvenTree.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
from InvenTree.validators import validate_build_order_reference
import common.models
import InvenTree.fields
import InvenTree.helpers
import InvenTree.tasks
from stock import models as StockModels
from part import models as PartModels
from stock import models as StockModels
from users import models as UserModels
@ -46,7 +47,7 @@ def get_next_build_number():
"""
if Build.objects.count() == 0:
return
return '0001'
build = Build.objects.exclude(reference=None).last()
@ -99,13 +100,28 @@ class Build(MPTTModel, ReferenceIndexingMixin):
return reverse('api-build-list')
def api_instance_filters(self):
return {
'parent': {
'exclude_tree': self.pk,
}
}
@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):
self.rebuild_reference_field()
@ -1014,6 +1030,19 @@ class Build(MPTTModel, ReferenceIndexingMixin):
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):
"""
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 %}
{% endif %}
<!-- Printing options -->
{% if report_enabled %}
<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'>
<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>
</ul>
</div>
{% endif %}
<!-- Build actions -->
{% if roles.build.change %}
<div class='btn-group'>
@ -224,9 +226,11 @@ src="{% static 'img/blank_image.png' %}"
{% endif %}
});
{% if report_enabled %}
$('#print-build-report').click(function() {
printBuildReports([{{ build.pk }}]);
});
{% endif %}
$("#build-delete").on('click', function() {
launchModalForm(

View File

@ -142,7 +142,7 @@
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Completed" %}</td>
{% 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 %}
<td><em>{% trans "Build not complete" %}</em></td>
{% endif %}
@ -160,9 +160,7 @@
<div class='panel-content'>
<div id='child-button-toolbar'>
<div class='button-toolbar container-fluid float-right'>
<div class='filter-list' id='filter-list-sub-build'>
<!-- Empty div for filters -->
</div>
{% include "filter_list.html" with id='sub-build' %}
</div>
</div>
<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-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Allocate Stock to Build" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@ -210,9 +208,7 @@
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
<span class='fas fa-sign-in-alt'></span>
</button>
<div class='filter-list' id='filter-list-builditems'>
<!-- Empty div for table filters-->
</div>
{% include "filter_list.html" with id='builditems' %}
</div>
</div>
</div>
@ -227,7 +223,7 @@
<div class='panel panel-hidden' id='panel-outputs'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Incomplete Build Outputs" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@ -251,7 +247,9 @@
<span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<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>
</div>
</div>
@ -276,7 +274,7 @@
<div class='panel panel-hidden' id='panel-attachments'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>

View File

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

View File

@ -5,7 +5,7 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from .models import InvenTreeSetting, InvenTreeUserSetting
import common.models
class SettingsAdmin(ImportExportModelAdmin):
@ -18,5 +18,11 @@ class UserSettingsAdmin(ImportExportModelAdmin):
list_display = ('key', 'value', 'user', )
admin.site.register(InvenTreeSetting, SettingsAdmin)
admin.site.register(InvenTreeUserSetting, UserSettingsAdmin)
class NotificationEntryAdmin(admin.ModelAdmin):
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 -*-
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 = [
# 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 decimal
import math
from datetime import datetime, timedelta
from django.db import models, transaction
from django.contrib.auth.models import User, Group
@ -33,6 +34,19 @@ import logging
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):
"""
An base InvenTreeSetting object is a key:value pair used for storing
@ -44,6 +58,16 @@ class BaseInvenTreeSetting(models.Model):
class Meta:
abstract = True
def save(self, *args, **kwargs):
"""
Enforce validation and clean before saving
"""
self.clean()
self.validate_unique()
super().save()
@classmethod
def allValues(cls, user=None):
"""
@ -342,6 +366,11 @@ class BaseInvenTreeSetting(models.Model):
except (ValueError):
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:
self.run_validator(validator)
@ -408,6 +437,18 @@ class BaseInvenTreeSetting(models.Model):
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):
"""
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)
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
def validator_is_bool(cls, validator):
@ -530,7 +585,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'INVENTREE_BASE_URL': {
'name': _('Base URL'),
'description': _('Base URL for server instance'),
'validator': URLValidator(),
'validator': EmptyURLValidator(),
'default': '',
},
@ -713,6 +768,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': InvenTree.validators.validate_part_name_format
},
'REPORT_ENABLE': {
'name': _('Enable Reports'),
'description': _('Enable generation of reports'),
'default': False,
'validator': bool,
},
'REPORT_DEBUG_MODE': {
'name': _('Debug Mode'),
'description': _('Generate reports in debug mode (HTML output)'),
@ -807,19 +869,19 @@ class InvenTreeSetting(BaseInvenTreeSetting):
# login / SSO
'LOGIN_ENABLE_PWD_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,
'validator': bool,
},
'LOGIN_ENABLE_REG': {
'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,
'validator': bool,
},
'LOGIN_ENABLE_SSO': {
'name': _('Enable SSO'),
'description': _('Enable SSO on the login-pages'),
'description': _('Enable SSO on the login pages'),
'default': False,
'validator': bool,
},
@ -849,7 +911,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
},
'SIGNUP_GROUP': {
'name': _('Group on signup'),
'description': _('Group new user are asigned on registration'),
'description': _('Group to which new users are assigned on registration'),
'default': '',
'choices': settings_group_options
},
@ -866,6 +928,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
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):
"""
@ -874,8 +944,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
GLOBAL_SETTINGS = {
'HOMEPAGE_PART_STARRED': {
'name': _('Show starred parts'),
'description': _('Show starred parts on the homepage'),
'name': _('Show subscribed parts'),
'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,
'validator': bool,
},
@ -1005,6 +1081,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'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': {
'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'),
@ -1063,6 +1146,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'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):
"""
@ -1220,3 +1311,63 @@ class ColorTheme(models.Model):
return True
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
"""
# -*- 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 -*-
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 -*-
from __future__ import unicode_literals
from datetime import timedelta
from django.test import TestCase
from django.contrib.auth import get_user_model
from .models import InvenTreeSetting
from .models import NotificationEntry
class SettingsTest(TestCase):
@ -85,3 +88,23 @@ class SettingsTest(TestCase):
if setting.default_value not in [True, False]:
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
from django.utils.translation import ugettext_lazy as _
from django.forms import CheckboxInput, Select
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from formtools.wizard.views import SessionWizardView
from crispy_forms.helper import FormHelper
from InvenTree.views import AjaxUpdateView, AjaxView
from InvenTree.helpers import str2bool
from InvenTree.views import AjaxView
from . import models
from . import forms
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):
""" 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-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Supplier Parts" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@ -46,9 +46,7 @@
</ul>
</div>
</div>
<div class='filter-list' id='filter-list-supplier-part'>
<!-- Empty div (will be filled out with available BOM filters) -->
</div>
{% include "filter_list.html" with id="supplier-part" %}
</div>
</div>
{% endif %}
@ -60,7 +58,7 @@
<div class='panel panel-hidden' id='panel-manufacturer-parts'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Manufacturer Parts" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@ -95,9 +93,7 @@
</ul>
</div>
</div>
<div class='filter-list' id='filter-list-supplier-part'>
<!-- Empty div (will be filled out with available BOM filters) -->
</div>
{% include "filter_list.html" with id="supplier-part" %}
</div>
</div>
{% endif %}
@ -117,7 +113,7 @@
<div class='panel panel-hidden' id='panel-purchase-orders'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Purchase Orders" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@ -132,9 +128,7 @@
<div class='panel-content'>
<div id='po-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='filter-list' id='filter-list-purchaseorder'>
<!-- Empty div -->
</div>
{% include "filter_list.html" with id="purchaseorder" %}
</div>
</div>
@ -145,7 +139,7 @@
<div class='panel panel-hidden' id='panel-sales-orders'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Sales Orders" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@ -160,9 +154,7 @@
<div class='panel-content'>
<div id='so-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='filter-list' id='filter-list-salesorder'>
<!-- Empty div -->
</div>
{% include "filter_list.html" with id="salesorder" %}
</div>
</div>
@ -177,9 +169,7 @@
</div>
<div class='panel-content'>
<div id='assigned-stock-button-toolbar'>
<div class='filter-list' id='filter-list-stock'>
<!-- An empty div in which the filter list will be constructed -->
</div>
{% include "filter_list.html" with id="stock" %}
</div>
<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-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Suppliers" %}</h4>
{% include "spacer.html" %}
<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-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Parameters" %}</h4>
{% include "spacer.html" %}
<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-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 class='panel-content'>
{% 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-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Supplier Part Orders" %}</h4>
{% include "spacer.html" %}
<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-heading'>
<div class='d-flex flex-row'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Pricing Information" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
@ -314,7 +322,6 @@ $("#item-create").click(function() {
part: {{ part.part.id }},
supplier_part: {{ part.id }},
},
reload: true,
});
});

View File

@ -83,7 +83,9 @@ class POLineItemResource(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)
@ -93,6 +95,17 @@ class SOLineItemResource(ModelResource):
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:
model = SalesOrderLineItem
skip_unchanged = True

View File

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

View File

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

View File

@ -29,7 +29,9 @@
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<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>
{% endif %}
<li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
</ul>
</div>
@ -123,7 +125,7 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-calendar-alt'></span></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>
{% if order.issue_date %}
<tr>
@ -143,7 +145,7 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-calendar-alt'></span></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>
{% endif %}
{% if order.responsible %}
@ -169,9 +171,11 @@ $("#place-order").click(function() {
});
{% endif %}
{% if report_enabled %}
$('#print-order-report').click(function() {
printPurchaseOrderReports([{{ order.pk }}]);
});
{% endif %}
$("#edit-order").click(function() {

View File

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

View File

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

View File

@ -16,7 +16,7 @@
{% block thumbnail %}
<img class='part-thumb'
{% if order.customer.image %}
{% if order.customer and order.customer.image %}
src="{{ order.customer.image.url }}"
{% else %}
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>
</button>
<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>
{% 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='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 %}
</td>
</tr>
{% if order.customer %}
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Customer" %}</td>
<td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a>{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if order.customer_reference %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
@ -128,7 +132,7 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-calendar-alt'></span></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>
{% if order.target_date %}
<tr>
@ -141,14 +145,14 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-truck'></span></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>
{% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %}
<tr>
<td><span class='fas fa-calendar-alt'></span></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>
{% endif %}
{% if order.responsible %}
@ -204,9 +208,11 @@ $("#ship-order").click(function() {
});
});
{% if report_enabled %}
$('#print-order-report').click(function() {
printSalesOrderReports([{{ order.pk }}]);
});
{% endif %}
$('#export-order').click(function() {
exportOrder('{% url "so-export" order.id %}');

View File

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

View File

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

View File

@ -350,6 +350,31 @@ class PurchaseOrderReceiveTest(OrderTest):
# No new stock items have been created
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):
"""
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
import import_export.widgets as widgets
from .models import PartCategory, Part
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
import part.models as models
from stock.models import StockLocation
from company.models import SupplierPart
@ -24,7 +18,7 @@ class PartResource(ModelResource):
""" Class for managing Part data import/export """
# 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))
@ -32,7 +26,7 @@ class PartResource(ModelResource):
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)
@ -48,7 +42,7 @@ class PartResource(ModelResource):
building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget())
class Meta:
model = Part
model = models.Part
skip_unchanged = True
report_skipped = False
clean_model_instances = True
@ -86,14 +80,14 @@ class PartAdmin(ImportExportModelAdmin):
class PartCategoryResource(ModelResource):
""" 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)
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
class Meta:
model = PartCategory
model = models.PartCategory
skip_unchanged = True
report_skipped = False
clean_model_instances = True
@ -108,14 +102,14 @@ class PartCategoryResource(ModelResource):
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
# Rebuild the PartCategory tree(s)
PartCategory.objects.rebuild()
models.PartCategory.objects.rebuild()
class PartCategoryInline(admin.TabularInline):
"""
Inline for PartCategory model
"""
model = PartCategory
model = models.PartCategory
class PartCategoryAdmin(ImportExportModelAdmin):
@ -146,6 +140,11 @@ class PartStarAdmin(admin.ModelAdmin):
list_display = ('part', 'user')
class PartCategoryStarAdmin(admin.ModelAdmin):
list_display = ('category', 'user')
class PartTestTemplateAdmin(admin.ModelAdmin):
list_display = ('part', 'test_name', 'required')
@ -159,7 +158,7 @@ class BomItemResource(ModelResource):
bom_id = Field(attribute='pk')
# 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
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)
# 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
part_ipn = Field(attribute='sub_part__IPN', readonly=True)
@ -233,7 +232,7 @@ class BomItemResource(ModelResource):
return fields
class Meta:
model = BomItem
model = models.BomItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
@ -262,16 +261,16 @@ class ParameterTemplateAdmin(ImportExportModelAdmin):
class ParameterResource(ModelResource):
""" 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)
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)
class Meta:
model = PartParameter
model = models.PartParameter
skip_unchanged = True
report_skipped = False
clean_model_instance = True
@ -292,7 +291,7 @@ class PartCategoryParameterAdmin(admin.ModelAdmin):
class PartSellPriceBreakAdmin(admin.ModelAdmin):
class Meta:
model = PartSellPriceBreak
model = models.PartSellPriceBreak
list_display = ('part', 'quantity', 'price',)
@ -300,20 +299,21 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
class PartInternalPriceBreakAdmin(admin.ModelAdmin):
class Meta:
model = PartInternalPriceBreak
model = models.PartInternalPriceBreak
list_display = ('part', 'quantity', 'price',)
admin.site.register(Part, PartAdmin)
admin.site.register(PartCategory, PartCategoryAdmin)
admin.site.register(PartRelated, PartRelatedAdmin)
admin.site.register(PartAttachment, PartAttachmentAdmin)
admin.site.register(PartStar, PartStarAdmin)
admin.site.register(BomItem, BomItemAdmin)
admin.site.register(PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(PartParameter, ParameterAdmin)
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin)
admin.site.register(models.Part, PartAdmin)
admin.site.register(models.PartCategory, PartCategoryAdmin)
admin.site.register(models.PartRelated, PartRelatedAdmin)
admin.site.register(models.PartAttachment, PartAttachmentAdmin)
admin.site.register(models.PartStar, PartStarAdmin)
admin.site.register(models.PartCategoryStar, PartCategoryStarAdmin)
admin.site.register(models.BomItem, BomItemAdmin)
admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(models.PartParameter, ParameterAdmin)
admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterAdmin)
admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)

View File

@ -58,6 +58,18 @@ class CategoryList(generics.ListCreateAPIView):
queryset = PartCategory.objects.all()
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):
"""
Custom filtering:
@ -110,6 +122,18 @@ class CategoryList(generics.ListCreateAPIView):
except (ValueError, PartCategory.DoesNotExist):
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
filter_backends = [
@ -149,6 +173,29 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = part_serializers.CategorySerializer
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):
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@ -389,7 +436,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
# Ensure the request context is passed through
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!
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()]
@ -418,9 +465,9 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
"""
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)

View File

@ -7,7 +7,7 @@ from collections import OrderedDict
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 .models import BomItem
@ -59,7 +59,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
uids = []
def add_items(items, level):
def add_items(items, level, cascade):
# Add items at a given layer
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)
if item.sub_part.assembly:
if cascade and item.sub_part.assembly:
if max_levels is None or level < max_levels:
add_items(item.sub_part.bom_items.all().order_by('id'), level + 1)
if cascade:
# Cascading (multi-level) BOM
top_level_items = part.get_bom_items().order_by('id')
# Start with the top level
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')]
add_items(top_level_items, 1, 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('')
except AttributeError:
stock_data.append('')
# 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):
try:
@ -160,171 +153,108 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# Add stock columns to dataset
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
"""
# Expand dataset with manufacturer parts
manufacturer_headers = [
_('Manufacturer'),
_('MPN'),
]
supplier_headers = [
_('Supplier'),
_('SKU'),
]
# Keep track of the supplier parts we have already exported
supplier_parts_used = set()
manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items):
for bom_idx, bom_item in enumerate(bom_items):
# Get part instance
b_part = bom_item.sub_part
# Filter manufacturer parts
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk)
manufacturer_parts = manufacturer_parts.prefetch_related('supplier_parts')
# Include manufacturer data for each BOM item
if manufacturer_data:
# Process manufacturer part
for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts):
# Filter manufacturer parts
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk).prefetch_related('supplier_parts')
for mp_idx, mp_part in enumerate(manufacturer_parts):
if manufacturer_part and manufacturer_part.manufacturer:
manufacturer_name = manufacturer_part.manufacturer.name
else:
manufacturer_name = ''
# Extract the "name" field of the Manufacturer (Company)
if mp_part and mp_part.manufacturer:
manufacturer_name = mp_part.manufacturer.name
else:
manufacturer_name = ''
if manufacturer_part:
manufacturer_mpn = manufacturer_part.MPN
else:
manufacturer_mpn = ''
# Extract the "MPN" field from the Manufacturer Part
if mp_part:
manufacturer_mpn = mp_part.MPN
else:
manufacturer_mpn = ''
# Generate column names for this manufacturer
k_man = manufacturer_headers[0] + "_" + str(manufacturer_idx)
k_mpn = manufacturer_headers[1] + "_" + str(manufacturer_idx)
# Generate a column name for this manufacturer
k_man = f'{_("Manufacturer")}_{mp_idx}'
k_mpn = f'{_("MPN")}_{mp_idx}'
try:
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}
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}
# We wish to include supplier data for this manufacturer part
if supplier_data:
for sp_idx, sp_part in enumerate(mp_part.supplier_parts.all()):
# Process supplier parts
for supplier_idx, supplier_part in enumerate(manufacturer_part.supplier_parts.all()):
supplier_parts_used.add(sp_part)
if supplier_part.supplier and supplier_part.supplier:
supplier_name = supplier_part.supplier.name
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:
supplier_name = ''
if supplier_part:
supplier_sku = supplier_part.SKU
else:
supplier_sku = ''
supplier_sku = sp_part.SKU
# Generate column names for this supplier
k_sup = str(supplier_headers[0]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx)
k_sku = str(supplier_headers[1]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx)
k_sup = str(_("Supplier")) + "_" + str(sp_idx)
k_sku = str(_("SKU")) + "_" + str(sp_idx)
try:
manufacturer_cols[k_sup].update({b_idx: supplier_name})
manufacturer_cols[k_sku].update({b_idx: supplier_sku})
manufacturer_cols[k_sup].update({bom_idx: supplier_name})
manufacturer_cols[k_sku].update({bom_idx: supplier_sku})
except KeyError:
manufacturer_cols[k_sup] = {b_idx: supplier_name}
manufacturer_cols[k_sku] = {b_idx: supplier_sku}
manufacturer_cols[k_sup] = {bom_idx: supplier_name}
manufacturer_cols[k_sku] = {bom_idx: supplier_sku}
# Add manufacturer 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 supplier columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items))
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.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 jinja2 import Template
@ -47,6 +47,7 @@ from InvenTree import validators
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money
import InvenTree.tasks
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
@ -56,6 +57,7 @@ from company.models import SupplierPart
from stock import models as StockModels
import common.models
import part.settings as part_settings
@ -102,11 +104,11 @@ class PartCategory(InvenTreeTree):
if cascade:
""" 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:
query = Part.objects.filter(category=self.pk)
queryset = Part.objects.filter(category=self.pk)
return query
return queryset
@property
def item_count(self):
@ -201,6 +203,60 @@ class PartCategory(InvenTreeTree):
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')
def before_delete_part_category(sender, instance, using, **kwargs):
@ -332,9 +388,16 @@ class Part(MPTTModel):
context = {}
context['starred'] = self.isStarredBy(request.user)
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
context['total_stock'] = self.total_stock
@ -1040,30 +1103,65 @@ class Part(MPTTModel):
return self.total_stock - self.allocation_count() + self.on_order
def isStarredBy(self, user):
""" 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):
def get_subscribers(self, include_variants=True, include_categories=True):
"""
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:
return
# Do not duplicate efforts
if self.isStarredBy(user) == starred:
# Already subscribed?
if self.is_starred_by(user) == status:
return
if starred:
if status:
PartStar.objects.create(part=self, user=user)
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()
def need_to_restock(self):
@ -1226,6 +1324,17 @@ class Part(MPTTModel):
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
def total_stock(self):
""" 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
"""
entries = self.stock_entries(in_stock=True)
query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
return query['t']
return self.get_stock_count()
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))
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):
"""
Return a query filter for all parts that this part is used in.
@ -1945,10 +2071,10 @@ class Part(MPTTModel):
if 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:
parts.append(sib)
for sib in siblings:
parts.append(sib)
filtered_parts = Part.objects.filter(pk__in=[part.pk for part in parts])
@ -1988,6 +2114,26 @@ class Part(MPTTModel):
def related_count(self):
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):
""" Function for storing a file for a PartAttachment
@ -2059,10 +2205,9 @@ class PartInternalPriceBreak(common.models.PriceBreak):
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,
so that the user can track a list of their favourite parts.
It is used to designate a Part as 'subscribed' for a given User.
Attributes:
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')
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):

View File

@ -33,12 +33,25 @@ from .models import (BomItem, BomItemSubstitute,
class CategorySerializer(InvenTreeModelSerializer):
""" 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)
parts = serializers.IntegerField(source='item_count', read_only=True)
level = serializers.IntegerField(read_only=True)
starred = serializers.SerializerMethodField()
class Meta:
model = PartCategory
fields = [
@ -51,6 +64,7 @@ class CategorySerializer(InvenTreeModelSerializer):
'parent',
'parts',
'pathstring',
'starred',
'url',
]
@ -241,6 +255,9 @@ class PartSerializer(InvenTreeModelSerializer):
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
queryset = queryset.annotate(
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

@ -34,11 +34,8 @@
<li><a class='dropdown-item' href='#' id='bom-item-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Items" %}</a></li>
</ul>
</div>
{% endif %}
<div class='filter-list' id='filter-list-bom'>
<!-- Empty div (will be filled out with avilable BOM filters) -->
</div>
{% endif %}
{% include "filter_list.html" with id="bom" %}
</div>
</div>

View File

@ -8,58 +8,55 @@
{% include "sidebar_link.html" with url=url text="Return to BOM" icon="fa-undo" %}
{% endblock %}
{% block page_content %}
{% block heading %}
{% trans "Upload Bill of Materials" %}
{% endblock %}
<div class='panel' id='panel-upload-file'>
<div class='panel-heading'>
{% block heading %}
<h4>{% trans "Upload Bill of Materials" %}</h4>
{{ wizard.form.media }}
{% endblock %}
{% block actions %}
{% endblock %}
{% block page_info %}
<div class='panel-content'>
<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 class='panel-content'>
{% block details %}
{% endblock %}
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
<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>
<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>
{% 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 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 %}
</div>
{% endblock page_info %}
{% block js_ready %}
{{ block.super }}
enableSidebar('bom-upload');
{% endblock js_ready %}

View File

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

View File

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

View File

@ -23,9 +23,19 @@
{% include "admin_button.html" with url=url %}
{% endif %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Star this part" %}'>
<span id='part-star-icon' class='fas fa-star {% if starred %}icon-yellow{% endif %}'/>
{% if starred_directly %}
<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>
{% 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 %}
<!-- Barcode actions menu -->
@ -137,8 +147,6 @@
</div>
</h4>
<!-- Part info messages -->
<div class='info-messages'>
{% if part.variant_of %}
@ -164,6 +172,13 @@
<td>{% trans "In Stock" %}</td>
<td>{% include "part/stock_count.html" %}</td>
</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 %}
<tr>
<td><span class='fas fa-shopping-cart'></span></td>
@ -310,7 +325,7 @@
$("#toggle-starred").click(function() {
toggleStar({
part: {{ part.id }},
url: '{% url "api-part-detail" part.pk %}',
button: '#part-star-icon',
});
});

View File

@ -122,6 +122,12 @@ def inventree_title(*args, **kwargs):
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()
def python_version(*args, **kwargs):
"""

View File

@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
import os
from .models import Part, PartCategory, PartTestTemplate
from .models import Part, PartCategory, PartCategoryStar, PartStar, PartTestTemplate
from .models import rename_part_image
from .templatetags import inventree_extras
@ -347,3 +347,120 @@ class PartSettingsTest(TestCase):
with self.assertRaises(ValidationError):
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
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.forms import UploadFileForm, MatchFieldForm
from stock.models import StockLocation
from stock.models import StockItem, StockLocation
import common.settings as inventree_settings
from . import forms as part_forms
from . import settings as part_settings
from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat
from order.models import PurchaseOrderLineItem
@ -245,6 +246,7 @@ class PartImport(FileManagementFormView):
'Category',
'default_location',
'default_supplier',
'variant_of',
]
OPTIONAL_HEADERS = [
@ -256,6 +258,17 @@ class PartImport(FileManagementFormView):
'minimum_stock',
'Units',
'Notes',
'Active',
'base_cost',
'Multiple',
'Assembly',
'Component',
'is_template',
'Purchaseable',
'Salable',
'Trackable',
'Virtual',
'Stock',
]
name = 'part'
@ -284,6 +297,18 @@ class PartImport(FileManagementFormView):
'category': 'category',
'default_location': 'default_location',
'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
@ -299,6 +324,8 @@ class PartImport(FileManagementFormView):
self.matches['default_location'] = ['name__contains']
self.allowed_items['default_supplier'] = SupplierPart.objects.all()
self.matches['default_supplier'] = ['SKU__contains']
self.allowed_items['variant_of'] = Part.objects.all()
self.matches['variant_of'] = ['name__contains']
# setup
self.file_manager.setup()
@ -364,9 +391,29 @@ class PartImport(FileManagementFormView):
category=optional_matches['Category'],
default_location=optional_matches['default_location'],
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:
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
except ValidationError as _e:
import_error.append(', '.join(set(_e.messages)))
@ -412,6 +459,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
part = self.get_object()
ctx = part.get_context_data(self.request)
context.update(**ctx)
# Pricing information
@ -1469,18 +1517,29 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
if category:
cascade = kwargs.get('cascade', True)
# Prefetch parts parameters
parts_parameters = category.prefetch_parts_parameters(cascade=cascade)
# Get table headers (unique parameters names)
context['headers'] = category.get_unique_parameters(cascade=cascade,
prefetch=parts_parameters)
# Insert part information
context['headers'].insert(0, 'description')
context['headers'].insert(0, 'part')
# Get parameters data
context['parameters'] = category.get_parts_parameters(cascade=cascade,
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

View File

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

View File

@ -14,6 +14,8 @@ from stock.models import StockItem
from common.models import InvenTreeSetting
import InvenTree.helpers
register = template.Library()
@ -119,18 +121,10 @@ def internal_link(link, 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 not base_url:
if not url:
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>')

View File

@ -117,6 +117,8 @@ class StockItemResource(ModelResource):
exclude = [
# Exclude MPTT internal model fields
'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 django.core.exceptions import ValidationError as DjangoValidationError
from django.conf.urls import url, include
from django.http import JsonResponse
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.serializers import ValidationError
from rest_framework.response import Response
from rest_framework import generics, filters
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
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
import common.settings
import common.models
from company.models import Company, SupplierPart
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 SalesOrder, SalesOrderAllocation
from order.serializers import POSerializer
import common.settings
import common.models
from part.models import BomItem, Part, PartCategory
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
from InvenTree.helpers import str2bool, isNull
from InvenTree.api import AttachmentMixin
from InvenTree.filters import InvenTreeOrderingFilter
class StockDetail(generics.RetrieveUpdateDestroyAPIView):
""" API detail endpoint for Stock object
@ -99,6 +101,27 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
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):
"""
A generic class for handling stocktake actions.
@ -380,28 +403,91 @@ class StockList(generics.ListCreateAPIView):
"""
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)
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
if 'location' not in request.data:
item.location = item.part.get_default_location()
quantity = data.get('quantity', None)
# An expiry date was *not* specified - try to infer it!
if 'expiry_date' not in request.data:
if quantity is None:
raise ValidationError({
'quantity': _('Quantity is required'),
})
if item.part.default_expiry > 0:
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
notes = data.get('notes', '')
# Finally, save the item
item.save(user=user)
serials = None
# Return a response
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
if serial_numbers:
# If serial numbers are specified, check that they match!
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):
"""
@ -790,6 +876,7 @@ class StockList(generics.ListCreateAPIView):
ordering_field_aliases = {
'SKU': 'supplier_part__SKU',
'stock': ['quantity', 'serial_int', 'serial'],
}
ordering_fields = [
@ -801,6 +888,7 @@ class StockList(generics.ListCreateAPIView):
'stocktake_date',
'expiry_date',
'quantity',
'stock',
'status',
'SKU',
]
@ -1085,8 +1173,11 @@ stock_api_urls = [
url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
])),
# Detail for a single stock item
url(r'^(?P<pk>\d+)/', StockDetail.as_view(), name='api-stock-detail'),
# Detail views for a single stock item
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
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
import os
import re
from django.utils.translation import gettext_lazy as _
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.core.validators import MinValueValidator
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 markdownx.models import MarkdownxField
@ -27,7 +28,9 @@ from mptt.managers import TreeManager
from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta
from InvenTree import helpers
import InvenTree.tasks
import common.models
import report.models
@ -221,6 +224,32 @@ class StockItem(MPTTModel):
self.scheduled_for_deletion = True
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):
"""
Save this StockItem to the database. Performs a number of checks:
@ -232,6 +261,8 @@ class StockItem(MPTTModel):
self.validate_unique()
self.clean()
self.update_serial_number()
user = kwargs.pop('user', None)
# 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'),
related_name='stock_items', help_text=_('Base part'),
limit_choices_to={
'active': True,
'virtual': False
})
@ -503,6 +533,8 @@ class StockItem(MPTTModel):
help_text=_('Serial number for this item')
)
serial_int = models.IntegerField(default=0)
link = InvenTreeURLField(
verbose_name=_('External Link'),
max_length=125, blank=True,
@ -1651,6 +1683,26 @@ def before_delete_stock_item(sender, instance, using, **kwargs):
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):
"""
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 django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import ugettext_lazy as _
from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value
@ -27,14 +28,15 @@ from .models import StockItemTestResult
import common.models
from common.settings import currency_code_default, currency_code_mappings
from company.serializers import SupplierPartSerializer
import InvenTree.helpers
import InvenTree.serializers
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
"""
@ -48,7 +50,7 @@ class LocationBriefSerializer(InvenTreeModelSerializer):
]
class StockItemSerializerBrief(InvenTreeModelSerializer):
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
""" Brief serializers for a StockItem """
location_name = serializers.CharField(source='location', read_only=True)
@ -58,19 +60,19 @@ class StockItemSerializerBrief(InvenTreeModelSerializer):
class Meta:
model = StockItem
fields = [
'pk',
'uid',
'part',
'part_name',
'supplier_part',
'pk',
'location',
'location_name',
'quantity',
'serial',
'supplier_part',
'uid',
]
class StockItemSerializer(InvenTreeModelSerializer):
class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Serializer for a StockItem:
- 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)
quantity = serializers.FloatField()
# quantity = serializers.FloatField()
allocated = serializers.FloatField(source='allocation_count', required=False)
@ -142,19 +144,22 @@ class StockItemSerializer(InvenTreeModelSerializer):
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)
purchase_price = InvenTreeMoneySerializer(
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
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(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
purchase_price_string = serializers.SerializerMethodField()
@ -196,6 +201,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'belongs_to',
'build',
'customer',
'delete_on_deplete',
'expired',
'expiry_date',
'in_stock',
@ -204,6 +210,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'location',
'location_detail',
'notes',
'owner',
'packaging',
'part',
'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:
model = StockItem
fields = ('quantity',)
fields = [
'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
"""
@ -273,7 +396,7 @@ class LocationSerializer(InvenTreeModelSerializer):
]
class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
""" Serializer for StockItemAttachment model """
def __init__(self, *args, **kwargs):
@ -284,9 +407,9 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
if user_detail is not True:
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!
@ -311,14 +434,14 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
]
class StockItemTestResultSerializer(InvenTreeModelSerializer):
class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" 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)
attachment = InvenTreeAttachmentSerializerField(required=False)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False)
def __init__(self, *args, **kwargs):
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 """
def __init__(self, *args, **kwargs):
@ -372,7 +495,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
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)

View File

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

View File

@ -53,6 +53,12 @@
</div>
<!-- Stock adjustment menu -->
<!-- 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 roles.stock.change and not item.is_building %}
<div class='btn-group'>
@ -393,7 +399,7 @@
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Last Stocktake" %}</td>
{% 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 %}
<td><em>{% trans "No stocktake performed" %}</em></td>
{% endif %}
@ -410,20 +416,33 @@
<td>{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}</td>
</tr>
{% endif %}
{% if item.owner %}
<tr>
<td><span class='fas fa-users'></span></td>
<td>{% trans "Owner" %}</td>
<td>{{ item.owner }}</td>
</tr>
{% endif %}
</table>
{% endblock %}
{% endblock details_right %}
{% block js_ready %}
{{ block.super }}
$("#stock-serialize").click(function() {
launchModalForm(
"{% url 'stock-item-serialize' item.id %}",
{
reload: true,
serializeStockItem({{ item.pk }}, {
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() {
@ -463,22 +482,16 @@ $("#print-label").click(function() {
{% if roles.stock.change %}
$("#stock-duplicate").click(function() {
createNewStockItem({
// Duplicate a stock item
duplicateStockItem({{ item.pk }}, {
follow: true,
data: {
copy: {{ item.id }},
}
});
});
$("#stock-edit").click(function () {
launchModalForm(
"{% url 'stock-item-edit' item.id %}",
{
reload: true,
submit_text: '{% trans "Save" %}',
}
);
$('#stock-edit').click(function() {
editStockItem({{ item.pk }}, {
reload: true,
});
});
$('#stock-edit-status').click(function () {

View File

@ -140,7 +140,15 @@
<div class='panel panel-hidden' id='panel-stock'>
<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 class='panel-content'>
{% 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>
</ul>
</div>
<div class='filter-list' id='filter-list-location'>
<!-- An empty div in which the filter list will be constructed -->
</div>
{% include "filter_list.html" with id="location" %}
</div>
</div>
@ -185,7 +191,8 @@
{% else %}
parent: 'null',
{% endif %}
}
},
allowTreeView: true,
});
linkButtonsToSelection(
@ -224,33 +231,21 @@
});
$('#location-create').click(function () {
launchModalForm("{% url 'stock-location-create' %}",
{
data: {
{% if location %}
location: {{ location.id }}
{% endif %}
},
follow: true,
secondary: [
{
field: 'parent',
label: '{% trans "New Location" %}',
title: '{% trans "Create new location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
return false;
createStockLocation({
{% if location %}
parent: {{ location.pk }},
{% endif %}
follow: true,
});
});
{% if location %}
$('#location-edit').click(function() {
launchModalForm("{% url 'stock-location-edit' location.id %}",
{
reload: true
});
return false;
editStockLocation({{ location.id }}, {
reload: true,
});
});
$('#location-delete').click(function() {
@ -313,12 +308,11 @@
$('#item-create').click(function () {
createNewStockItem({
follow: true,
data: {
{% if location %}
location: {{ location.id }}
{% 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'>
{% 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 %}
</ul>
{% endif %}

View File

@ -364,24 +364,22 @@ class StockItemTest(StockAPITestCase):
'part': 1,
'location': 1,
},
expected_code=201,
expected_code=400
)
# Item should have been created with default quantity
self.assertEqual(response.data['quantity'], 1)
self.assertIn('Quantity is required', str(response.data))
# POST with quantity and part and location
response = self.client.post(
response = self.post(
self.list_url,
data={
'part': 1,
'location': 1,
'quantity': 10,
}
},
expected_code=201
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_default_expiry(self):
"""
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
import json
from datetime import datetime, timedelta
from InvenTree.status_codes import StockStatus
class StockViewTestCase(TestCase):
@ -63,149 +58,6 @@ class StockListTest(StockViewTestCase):
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):
""" Tests for stock ownership views """
@ -248,52 +100,39 @@ class StockOwnershipTest(StockViewTestCase):
InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user)
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):
# Test stock location and item ownership
from .models import StockLocation, StockItem
from .models import StockLocation
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_owner = Owner.get_owner(new_user_group)
user_as_owner = Owner.get_owner(self.user)
new_user_as_owner = Owner.get_owner(self.new_user)
test_location_id = 4
test_item_id = 11
# Enable ownership control
self.enable_ownership()
# Set ownership on existing location
response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)),
{'name': 'Office', 'owner': user_group_owner.pk},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
test_location_id = 4
test_item_id = 11
# Set ownership on existing item (and change location)
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
{'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
# Logout
self.client.logout()
# Login with new user
self.client.login(username='john', password='custom123')
# Test location edit
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)
# TODO: Refactor this following test to use the new API form
# Test item edit
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
{'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk},
@ -310,38 +149,6 @@ class StockOwnershipTest(StockViewTestCase):
'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
location_created = StockLocation.objects.get(name=new_location['name'])
@ -372,16 +179,4 @@ class StockOwnershipTest(StockViewTestCase):
# 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 = [
url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
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'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'),
@ -22,9 +19,7 @@ location_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'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
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'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
@ -50,8 +45,6 @@ stock_urls = [
# Stock location
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'^track/', include(stock_tracking_urls)),

View File

@ -149,6 +149,10 @@ class StockLocationEdit(AjaxUpdateView):
"""
View for editing details of a StockLocation.
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
@ -556,9 +560,8 @@ class StockItemInstall(AjaxUpdateView):
# Filter for parts to install in this item
if self.install_item:
# Get parts used in this part's BOM
bom_items = self.part.get_bom_items()
allowed_parts = [item.sub_part for item in bom_items]
# Get all parts which can be installed into this part
allowed_parts = self.part.get_installed_part_options()
# Filter
items = items.filter(part__in=allowed_parts)
@ -927,6 +930,10 @@ class StockLocationCreate(AjaxCreateView):
"""
View for creating a new StockLocation
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
@ -1019,89 +1026,6 @@ class StockLocationCreate(AjaxCreateView):
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):
"""
View for creating a new StockItem

View File

@ -7,6 +7,8 @@
{% inventree_title %} | {% trans "Index" %}
{% endblock %}
{% block breadcrumb_list %}
{% endblock %}
{% block sidebar %}
<!-- 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_CATEGORY_STARRED' user=request.user as setting_category_starred %}
{% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %}
{% 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 %}
@ -82,15 +85,25 @@ function addHeaderAction(label, title, icon, options) {
addHeaderTitle('{% trans "Parts" %}');
{% 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' %}", {
params: {
"starred": true,
starred: true,
},
name: 'starred_parts',
});
{% 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 %}
addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper');
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 %}
{% endif %}
{% if roles.stock.view and True in settings_list_stock %}
addHeaderTitle('{% trans "Stock" %}');
{% if roles.stock.view %}
{% if setting_stock_recent %}
addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock');
@ -143,7 +155,7 @@ loadStockTable($('#table-recently-updated-stock'), {
{% endif %}
{% 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' %}", {
params: {
low_stock: true,

View File

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

View File

@ -7,6 +7,12 @@
{% trans "Category Settings" %}
{% 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 %}
<div class='row'>
@ -21,12 +27,6 @@
</form>
</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>

View File

@ -13,29 +13,31 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %}
</tbody>
</table>
<table class='table table-striped table-condensed'>
<tbody>
<tr>
<td></td>
<th>{% trans "Base Currency" %}</th>
<th>{{ base_currency }}</th>
</tr>
<tr>
<th colspan='2'>{% trans "Exchange Rates" %}</th>
<td></td>
<th colspan='4'>{% trans "Exchange Rates" %}</th>
</tr>
{% for rate in rates %}
<tr>
<td>{{ rate.currency }}</td>
<td></td>
<td>{{ rate.value }}</td>
<td>{{ rate.currency }}</td>
<td></td>
<td></td>
</tr>
{% endfor %}
<tr>
<th></th>
<th>
{% trans "Last Update" %}
</th>
<td>
<td colspan="3">
{% if rates_updated %}
{{ rates_updated }}
{% else %}
@ -44,7 +46,7 @@
<form action='{% url "settings-currencies-refresh" %}' method='post'>
<div id='refresh-rates-form'>
{% 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>
</form>
</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_MAIL_REQUIRED" icon="fa-info-circle" %}
<tr>
<td>{% trans 'Signup' %}</td>
<th><h5>{% trans 'Signup' %}</h5></th>
<td colspan='4'></td>
</tr>
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %}

View File

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

View File

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

View File

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

View File

@ -11,18 +11,18 @@
{% trans "Account Settings" %}
{% 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 %}
{% 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'>
<tr>
<td>{% trans "Username" %}</td>
@ -39,61 +39,81 @@
</table>
<div class='panel-heading'>
<h4>{% trans "Email" %}</h4>
<div class='d-flex flex-span'>
<h4>{% trans "Email" %}</h4>
{% include "spacer.html" %}
</div>
</div>
<div>
{% if user.emailaddress_set.all %}
<p>{% trans 'The following email addresses are associated with your account:' %}</p>
<div class='row'>
<div class='col-sm-6'>
{% 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">
{% csrf_token %}
<fieldset class="blockLabels">
<form action="{% url 'account_email' %}" class="email_list" method="post">
{% csrf_token %}
<fieldset class="blockLabels">
{% for emailaddress in user.emailaddress_set.all %}
<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}}"/>
{{ emailaddress.email }}
{% if emailaddress.verified %}
<span class="verified">{% trans "Verified" %}</span>
{% else %}
<span class="unverified">{% trans "Unverified" %}</span>
{% endif %}
{% if emailaddress.primary %}<span class="primary">{% trans "Primary" %}</span>{% endif %}
</label>
{% for emailaddress in user.emailaddress_set.all %}
<div>
<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}}"/>
{% if emailaddress.primary %}
<b>{{ emailaddress.email }}</b>
{% else %}
{{ emailaddress.email }}
{% endif %}
</label>
{% if emailaddress.verified %}
<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>
{% endfor %}
{% endfor %}
<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_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>
</div>
<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_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>
</div>
</fieldset>
</form>
</fieldset>
</form>
{% else %}
<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." %}
</p>
{% else %}
<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." %}
</p>
{% endif %}
{% if can_add_email %}
<br>
<h4>{% trans "Add Email Address" %}</h4>
{% endif %}
</div>
<div class='col-sm-6'>
{% if can_add_email %}
<h5>{% trans "Add Email Address" %}</h5>
<form method="post" action="{% url 'account_email' %}" class="add_email">
{% 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>
{% endif %}
<br>
</div>
</div>
<div class='panel-heading'>
@ -135,7 +155,9 @@
</form>
{% 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 %}
<br>
@ -155,26 +177,26 @@
<div class='row'>
<form action='{% url "settings-appearance" %}' method='post'>
{% csrf_token %}
<input name='next' type='hidden' value='{% url "settings" %}'>
<div class="col-sm-6" style="width: 200px;">
<div id="div_id_themes" class="form-group">
<div class="controls ">
<select name='theme' class='select form-control'>
{% get_available_themes as themes %}
{% for theme in themes %}
<option value='{{ theme.key }}'>{{ theme.name }}</option>
{% endfor %}
</select>
<div class='col-sm-6'>
<form action='{% url "settings-appearance" %}' method='post'>
{% csrf_token %}
<input name='next' type='hidden' value='{% url "settings" %}'>
<label for='theme' class=' requiredField'>
{% trans "Select theme" %}
</label>
<div class='form-group input-group mb-3'>
<select id='theme' name='theme' class='select form-control'>
{% get_available_themes as themes %}
{% for theme in themes %}
<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 class="col-sm-6" style="width: auto;">
<input type="submit" value="{% trans 'Set Theme' %}" class="btn btn btn-primary">
</div>
</form>
</form>
</div>
</div>
<div class='panel-heading'>
@ -186,29 +208,43 @@
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<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 ">
<select name="language" class="select form-control">
<label for='language' class=' requiredField'>
{% 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_available_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 %}
{% define language.code as lang_code %}
{% 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 %}>
{{ language.name_local }} ({{ lang_code }})
{% if lang_translated %}
{% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %}
{% else %}
{% trans 'No translations available' %}
{% if lang_code == 'en' %}-{% else %}{% trans 'No translations available' %}{% endif %}
{% endif %}
</option>
{% endif %}
{% endfor %}
</select>
</div></div></div>
<div class="col-sm-6" style="width: auto;">
<input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary">
<div class='input-group-append'>
<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>
</form>
</form>
</div>
<div class="col-sm-6">
<h4>{% trans "Help the translation efforts!" %}</h4>

View File

@ -14,7 +14,8 @@
<div class='row'>
<table class='table table-striped table-condensed'>
<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="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %}

View File

@ -16,6 +16,7 @@
<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_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>
</table>
</div>

View File

@ -22,12 +22,12 @@
<a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a>{% include "clip.html" %}
{% inventree_is_development as 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 %}
{% 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 %}
<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 %}
</td>

View File

@ -10,10 +10,30 @@
<meta charset="utf-8">
<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 -->
<link rel="stylesheet" href="{% static 'fontawesome/css/brands.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-bootstrap-5-theme.css' %}">
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
@ -33,41 +53,60 @@
<!--
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="row">
<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"/>
<span><h3>{% inventree_title %}</h3></span>
{% include "spacer.html" %}
<span class='float-right'><h3>{% inventree_title %}</h3></span>
</div>
<hr>
<div class='container-fluid'>{% block content %}{% endblock %}</div>
</div>
<div class='container-fluid'>
<hr>
{% block content %}
{% endblock %}
</div>
</div>
</div>
{% block extra_body %}
{% endblock %}
{% include 'notification.html' %}
</div>
<!-- Scripts -->
<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 -->
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<!-- dynamic javascript templates -->
<script type='text/javascript' src="{% url 'inventree.js' %}"></script>
<!-- fontawesome -->
<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/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'>
@ -75,12 +114,16 @@ $(document).ready(function () {
// notifications
{% if messages %}
{% for message in messages %}
showAlertOrCache('alert-info', '{{message}}', true);
showAlertOrCache(
'{{ message }}',
true,
{
style: 'info',
}
);
{% endfor %}
{% endif %}
showCachedAlerts();
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 }}" />
{% endif %}
<div class="btn-toolbar">
<button class="btn btn-primary col-md-8" type="submit">{% trans "Sign In" %}</button>
{% if mail_conf and enable_pwd_forgot %}
<a class="btn btn-primary" href="{% url 'account_reset_password' %}">{% trans "Forgot Password?" %}</a>
{% endif %}
</div>
<hr>
<div class="btn-group float-right" role="group">
<button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
</div>
{% if mail_conf and enable_pwd_forgot %}
<a class="" href="{% url 'account_reset_password' %}"><small>{% trans "Forgot Password?" %}</small></a>
{% endif %}
</form>
{% if enable_sso %}

View File

@ -14,7 +14,11 @@
{% if redirect_field_value %}
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/>
{% 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>

View File

@ -14,7 +14,9 @@
<form method="POST" action="{{ action_url }}">
{% csrf_token %}
{{ 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>
{% else %}
<p>{% trans 'Your password is now changed.' %}</p>

View File

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

View File

@ -4,6 +4,7 @@
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
{% settings_value "REPORT_ENABLE" as report_enabled %}
<!DOCTYPE html>
<html lang="en">
@ -83,7 +84,14 @@
</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 %}
<div class='container-fluid navigation'>
<nav aria-label='breadcrumb'>
@ -102,7 +110,6 @@
</div>
{% include 'modals.html' %}
{% include 'about.html' %}
{% include 'notification.html' %}
</div>
<!-- Scripts -->
@ -135,9 +142,9 @@
<!-- general InvenTree -->
<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 -->
<script type='text/javascript' src="{% url 'inventree.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 'settings.js' %}"></script>
@ -177,15 +184,25 @@ $(document).ready(function () {
inventreeDocReady();
showCachedAlerts();
{% if barcodes %}
$('#barcode-scan').click(function() {
barcodeScanDialog();
});
{% 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>

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 %}
/* exported
editSetting,
user_settings,
global_settings,
*/
@ -18,3 +19,83 @@ const global_settings = {
{{ key }}: {% primitive_to_javascript value %},
{% 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 %}
/* globals
renderErrorMessage,
showAlertDialog,
*/
/* exported
@ -63,11 +61,17 @@ function inventreeGet(url, filters={}, options={}) {
},
error: function(xhr, ajaxOptions, thrownError) {
console.error('Error on GET at ' + url);
console.error(thrownError);
if (thrownError) {
console.error('Error: ' + thrownError);
}
if (options.error) {
options.error({
error: thrownError
});
} else {
showApiError(xhr, url);
}
}
});
@ -104,6 +108,8 @@ function inventreeFormDataUpload(url, data, options={}) {
if (options.error) {
options.error(xhr, status, error);
} else {
showApiError(xhr, url);
}
}
});
@ -139,6 +145,8 @@ function inventreePut(url, data={}, options={}) {
} else {
console.error(`Error on ${method} to '${url}' - STATUS ${xhr.status}`);
console.error(thrownError);
showApiError(xhr, url);
}
},
complete: function(xhr, status) {
@ -162,13 +170,15 @@ function inventreeDelete(url, options={}) {
return inventreePut(url, {}, options);
}
function showApiError(xhr) {
/*
* Display a notification with error information
*/
function showApiError(xhr, url) {
var title = null;
var message = null;
switch (xhr.status) {
switch (xhr.status || 0) {
// No response
case 0:
title = '{% trans "No Response" %}';
@ -208,7 +218,11 @@ function showApiError(xhr) {
}
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,
modalShowSubmitButton,
modalSubmit,
showAlertOrCache,
showQuestionDialog,
*/
@ -36,7 +35,7 @@ function makeBarcodeInput(placeholderText='', hintText='') {
<label class='control-label' for='barcode'>{% trans "Barcode" %}</label>
<div class='controls'>
<div class='input-group'>
<span class='input-group-addon'>
<span class='input-group-text'>
<span class='fas fa-qrcode'></span>
</span>
<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>
<div class='controls'>
<div class='input-group'>
<span class='input-group-addon'>
<span class='input-group-text'>
<span class='fas fa-sticky-note'></span>
</span>
<input id='notes' class='textinput textInput form-control' type='text' name='notes' placeholder='${placeholder}'>
@ -258,7 +257,7 @@ function barcodeDialog(title, options={}) {
$(modal).modal({
backdrop: 'static',
keyboard: false,
keyboard: user_settings.FORMS_CLOSE_USING_ESCAPE,
});
if (options.preShow) {
@ -480,10 +479,13 @@ function barcodeCheckIn(location_id) {
$(modal).modal('hide');
if (status == 'success' && 'success' in response) {
showAlertOrCache('alert-success', response.success, true);
addCachedAlert(response.success);
location.reload();
} 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');
if (status == 'success' && 'success' in response) {
showAlertOrCache('alert-success', response.success, true);
addCachedAlert(response.success);
location.reload();
} 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