merge upstream

This commit is contained in:
Matthias 2021-11-04 23:44:41 +01:00
commit df72cb2608
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
68 changed files with 2310 additions and 1115 deletions

View File

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

View File

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

View File

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

View File

@ -28,9 +28,8 @@
padding: 20px; padding: 20px;
padding-bottom: 35px; padding-bottom: 35px;
background-color: rgba(50, 50, 50, 0.75); background-color: rgba(50, 50, 50, 0.75);
width: 100%; width: 100%;
max-width: 350px; max-width: 550px;
margin: auto; margin: auto;
} }
@ -180,10 +179,6 @@
float: right; float: right;
} }
.starred-part {
color: #ffbb00;
}
.red-cell { .red-cell {
background-color: #ec7f7f; background-color: #ec7f7f;
} }
@ -565,6 +560,12 @@
transition: 0.1s; transition: 0.1s;
} }
.search-autocomplete-item {
border-top: 1px solid #EEE;
margin-bottom: 2px;
overflow-x: hidden;
}
.modal { .modal {
overflow: hidden; overflow: hidden;
z-index: 9999; z-index: 9999;
@ -745,13 +746,7 @@ input[type="submit"] {
} }
.notification-area { .notification-area {
position: fixed; opacity: 0.8;
top: 0px;
margin-top: 20px;
width: 100%;
padding: 20px;
z-index: 5000;
pointer-events: none; /* Prevent this div from blocking links underneath */
} }
.notes { .notes {
@ -761,7 +756,6 @@ input[type="submit"] {
} }
.alert { .alert {
display: none;
border-radius: 5px; border-radius: 5px;
opacity: 0.9; opacity: 0.9;
pointer-events: all; pointer-events: all;
@ -771,9 +765,8 @@ input[type="submit"] {
display: block; display: block;
} }
.btn { .navbar .btn {
margin-left: 2px; margin-left: 5px;
margin-right: 2px;
} }
.btn-secondary { .btn-secondary {

View File

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

View File

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

View File

@ -94,7 +94,6 @@ settings_urls = [
# These javascript files are served "dynamically" - i.e. rendered on demand # These javascript files are served "dynamically" - i.e. rendered on demand
dynamic_javascript_urls = [ dynamic_javascript_urls = [
url(r'^inventree.js', DynamicJsView.as_view(template_name='js/dynamic/inventree.js'), name='inventree.js'),
url(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'), url(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'),
url(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'), url(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'),
url(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'), url(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'),

View File

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

View File

@ -9,16 +9,16 @@ import decimal
import os import os
from datetime import datetime from datetime import datetime
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.urls import reverse
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Sum, Q from django.db.models import Sum, Q
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator from django.db.models.signals import post_save
from django.dispatch.dispatcher import receiver
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
@ -27,16 +27,17 @@ from mptt.exceptions import InvalidMove
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
from InvenTree.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
from InvenTree.validators import validate_build_order_reference
import common.models import common.models
import InvenTree.fields import InvenTree.fields
import InvenTree.helpers import InvenTree.helpers
import InvenTree.tasks
from stock import models as StockModels
from part import models as PartModels from part import models as PartModels
from stock import models as StockModels
from users import models as UserModels from users import models as UserModels
@ -1014,6 +1015,19 @@ class Build(MPTTModel, ReferenceIndexingMixin):
return self.status == BuildStatus.COMPLETE return self.status == BuildStatus.COMPLETE
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
def after_save_build(sender, instance: Build, created: bool, **kwargs):
"""
Callback function to be executed after a Build instance is saved
"""
if created:
# A new Build has just been created
# Run checks on required parts
InvenTree.tasks.offload_task('build.tasks.check_build_stock', instance)
class BuildOrderAttachment(InvenTreeAttachment): class BuildOrderAttachment(InvenTreeAttachment):
""" """
Model for storing file attachments against a BuildOrder object Model for storing file attachments against a BuildOrder object

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

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

View File

@ -142,7 +142,7 @@
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Completed" %}</td> <td>{% trans "Completed" %}</td>
{% if build.completion_date %} {% if build.completion_date %}
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td> <td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge badge-right rounded-pill bg-dark'>{{ build.completed_by }}</span>{% endif %}</td>
{% else %} {% else %}
<td><em>{% trans "Build not complete" %}</em></td> <td><em>{% trans "Build not complete" %}</em></td>
{% endif %} {% endif %}
@ -247,7 +247,9 @@
<span class='fas fa-tools'></span> <span class='caret'></span> <span class='fas fa-tools'></span> <span class='caret'></span>
</button> </button>
<ul class='dropdown-menu'> <ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'><span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'>
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
</a></li>
</ul> </ul>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -9,6 +9,7 @@ from __future__ import unicode_literals
import os import os
import decimal import decimal
import math import math
from datetime import datetime, timedelta
from django.db import models, transaction from django.db import models, transaction
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
@ -880,8 +881,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
GLOBAL_SETTINGS = { GLOBAL_SETTINGS = {
'HOMEPAGE_PART_STARRED': { 'HOMEPAGE_PART_STARRED': {
'name': _('Show starred parts'), 'name': _('Show subscribed parts'),
'description': _('Show starred parts on the homepage'), 'description': _('Show subscribed parts on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_CATEGORY_STARRED': {
'name': _('Show subscribed categories'),
'description': _('Show subscribed part categories on the homepage'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
}, },
@ -1011,6 +1018,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'SEARCH_HIDE_INACTIVE_PARTS': {
'name': _("Hide Inactive Parts"),
'description': _('Hide inactive parts in search preview window'),
'default': False,
'validator': bool,
},
'PART_SHOW_QUANTITY_IN_FORMS': { 'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'), 'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'), 'description': _('Display available part quantity in some forms'),
@ -1226,3 +1240,63 @@ class ColorTheme(models.Model):
return True return True
return False return False
class NotificationEntry(models.Model):
"""
A NotificationEntry records the last time a particular notifaction was sent out.
It is recorded to ensure that notifications are not sent out "too often" to users.
Attributes:
- key: A text entry describing the notification e.g. 'part.notify_low_stock'
- uid: An (optional) numerical ID for a particular instance
- date: The last time this notification was sent
"""
class Meta:
unique_together = [
('key', 'uid'),
]
key = models.CharField(
max_length=250,
blank=False,
)
uid = models.IntegerField(
)
updated = models.DateTimeField(
auto_now=True,
null=False,
)
@classmethod
def check_recent(cls, key: str, uid: int, delta: timedelta):
"""
Test if a particular notification has been sent in the specified time period
"""
since = datetime.now().date() - delta
entries = cls.objects.filter(
key=key,
uid=uid,
updated__gte=since
)
return entries.exists()
@classmethod
def notify(cls, key: str, uid: int):
"""
Notify the database that a particular notification has been sent out
"""
entry, created = cls.objects.get_or_create(
key=key,
uid=uid
)
entry.save()

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

View File

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

View File

@ -123,7 +123,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td> <td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge'>{{ order.created_by }}</span></td> <td>{{ order.creation_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td>
</tr> </tr>
{% if order.issue_date %} {% if order.issue_date %}
<tr> <tr>
@ -143,7 +143,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td> <td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td> <td>{{ order.complete_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.responsible %} {% if order.responsible %}

View File

@ -50,7 +50,7 @@
<h4>{% trans "Received Items" %}</h4> <h4>{% trans "Received Items" %}</h4>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% include "stock_table.html" with prevent_new_stock=True %} {% include "stock_table.html" %}
</div> </div>
</div> </div>

View File

@ -128,7 +128,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td> <td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge'>{{ order.created_by }}</span></td> <td>{{ order.creation_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td>
</tr> </tr>
{% if order.target_date %} {% if order.target_date %}
<tr> <tr>
@ -141,14 +141,14 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-truck'></span></td> <td><span class='fas fa-truck'></span></td>
<td>{% trans "Shipped" %}</td> <td>{% trans "Shipped" %}</td>
<td>{{ order.shipment_date }}<span class='badge'>{{ order.shipped_by }}</span></td> <td>{{ order.shipment_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.shipped_by }}</span></td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %} {% if order.status == PurchaseOrderStatus.COMPLETE %}
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td> <td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td> <td>{{ order.complete_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.responsible %} {% if order.responsible %}

View File

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

View File

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

View File

@ -160,171 +160,108 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# Add stock columns to dataset # Add stock columns to dataset
add_columns_to_dataset(stock_cols, len(bom_items)) add_columns_to_dataset(stock_cols, len(bom_items))
if manufacturer_data and supplier_data: if manufacturer_data or supplier_data:
""" """
If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item
""" """
# Expand dataset with manufacturer parts # Keep track of the supplier parts we have already exported
manufacturer_headers = [ supplier_parts_used = set()
_('Manufacturer'),
_('MPN'),
]
supplier_headers = [
_('Supplier'),
_('SKU'),
]
manufacturer_cols = {} manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items): for bom_idx, bom_item in enumerate(bom_items):
# Get part instance # Get part instance
b_part = bom_item.sub_part b_part = bom_item.sub_part
# Filter manufacturer parts # Include manufacturer data for each BOM item
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk) if manufacturer_data:
manufacturer_parts = manufacturer_parts.prefetch_related('supplier_parts')
# Process manufacturer part # Filter manufacturer parts
for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts): manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk).prefetch_related('supplier_parts')
for mp_idx, mp_part in enumerate(manufacturer_parts):
if manufacturer_part and manufacturer_part.manufacturer: # Extract the "name" field of the Manufacturer (Company)
manufacturer_name = manufacturer_part.manufacturer.name if mp_part and mp_part.manufacturer:
else: manufacturer_name = mp_part.manufacturer.name
manufacturer_name = '' else:
manufacturer_name = ''
if manufacturer_part: # Extract the "MPN" field from the Manufacturer Part
manufacturer_mpn = manufacturer_part.MPN if mp_part:
else: manufacturer_mpn = mp_part.MPN
manufacturer_mpn = '' else:
manufacturer_mpn = ''
# Generate column names for this manufacturer # Generate a column name for this manufacturer
k_man = manufacturer_headers[0] + "_" + str(manufacturer_idx) k_man = f'{_("Manufacturer")}_{mp_idx}'
k_mpn = manufacturer_headers[1] + "_" + str(manufacturer_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: # We wish to include supplier data for this manufacturer part
manufacturer_cols[k_man].update({b_idx: manufacturer_name}) if supplier_data:
manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn})
except KeyError: for sp_idx, sp_part in enumerate(mp_part.supplier_parts.all()):
manufacturer_cols[k_man] = {b_idx: manufacturer_name}
manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn}
# Process supplier parts supplier_parts_used.add(sp_part)
for supplier_idx, supplier_part in enumerate(manufacturer_part.supplier_parts.all()):
if supplier_part.supplier and supplier_part.supplier: if sp_part.supplier and sp_part.supplier:
supplier_name = supplier_part.supplier.name supplier_name = sp_part.supplier.name
else:
supplier_name = ''
if sp_part:
supplier_sku = sp_part.SKU
else:
supplier_sku = ''
# Generate column names for this supplier
k_sup = str(_("Supplier")) + "_" + str(mp_idx) + "_" + str(sp_idx)
k_sku = str(_("SKU")) + "_" + str(mp_idx) + "_" + str(sp_idx)
try:
manufacturer_cols[k_sup].update({bom_idx: supplier_name})
manufacturer_cols[k_sku].update({bom_idx: supplier_sku})
except KeyError:
manufacturer_cols[k_sup] = {bom_idx: supplier_name}
manufacturer_cols[k_sku] = {bom_idx: supplier_sku}
if supplier_data:
# Add in any extra supplier parts, which are not associated with a manufacturer part
for sp_idx, sp_part in enumerate(SupplierPart.objects.filter(part__pk=b_part.pk)):
if sp_part in supplier_parts_used:
continue
supplier_parts_used.add(sp_part)
if sp_part.supplier:
supplier_name = sp_part.supplier.name
else: else:
supplier_name = '' supplier_name = ''
if supplier_part: supplier_sku = sp_part.SKU
supplier_sku = supplier_part.SKU
else:
supplier_sku = ''
# Generate column names for this supplier # Generate column names for this supplier
k_sup = str(supplier_headers[0]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx) k_sup = str(_("Supplier")) + "_" + str(sp_idx)
k_sku = str(supplier_headers[1]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx) k_sku = str(_("SKU")) + "_" + str(sp_idx)
try: try:
manufacturer_cols[k_sup].update({b_idx: supplier_name}) manufacturer_cols[k_sup].update({bom_idx: supplier_name})
manufacturer_cols[k_sku].update({b_idx: supplier_sku}) manufacturer_cols[k_sku].update({bom_idx: supplier_sku})
except KeyError: except KeyError:
manufacturer_cols[k_sup] = {b_idx: supplier_name} manufacturer_cols[k_sup] = {bom_idx: supplier_name}
manufacturer_cols[k_sku] = {b_idx: supplier_sku} manufacturer_cols[k_sku] = {bom_idx: supplier_sku}
# Add manufacturer columns to dataset # Add supplier columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items))
elif manufacturer_data:
"""
If requested, add extra columns for each ManufacturerPart associated with each line item
"""
# Expand dataset with manufacturer parts
manufacturer_headers = [
_('Manufacturer'),
_('MPN'),
]
manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items):
# Get part instance
b_part = bom_item.sub_part
# Filter supplier parts
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk)
for idx, manufacturer_part in enumerate(manufacturer_parts):
if manufacturer_part:
manufacturer_name = manufacturer_part.manufacturer.name
else:
manufacturer_name = ''
manufacturer_mpn = manufacturer_part.MPN
# Add manufacturer data to the manufacturer columns
# Generate column names for this manufacturer
k_man = manufacturer_headers[0] + "_" + str(idx)
k_mpn = manufacturer_headers[1] + "_" + str(idx)
try:
manufacturer_cols[k_man].update({b_idx: manufacturer_name})
manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn})
except KeyError:
manufacturer_cols[k_man] = {b_idx: manufacturer_name}
manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn}
# Add manufacturer columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items))
elif supplier_data:
"""
If requested, add extra columns for each SupplierPart associated with each line item
"""
# Expand dataset with manufacturer parts
manufacturer_headers = [
_('Supplier'),
_('SKU'),
]
manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items):
# Get part instance
b_part = bom_item.sub_part
# Filter supplier parts
supplier_parts = SupplierPart.objects.filter(part__pk=b_part.pk)
for idx, supplier_part in enumerate(supplier_parts):
if supplier_part.supplier:
supplier_name = supplier_part.supplier.name
else:
supplier_name = ''
supplier_sku = supplier_part.SKU
# Add manufacturer data to the manufacturer columns
# Generate column names for this supplier
k_sup = manufacturer_headers[0] + "_" + str(idx)
k_sku = manufacturer_headers[1] + "_" + str(idx)
try:
manufacturer_cols[k_sup].update({b_idx: supplier_name})
manufacturer_cols[k_sku].update({b_idx: supplier_sku})
except KeyError:
manufacturer_cols[k_sup] = {b_idx: supplier_name}
manufacturer_cols[k_sku] = {b_idx: supplier_sku}
# Add manufacturer columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items)) add_columns_to_dataset(manufacturer_cols, len(bom_items))
data = dataset.export(fmt) data = dataset.export(fmt)

View File

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

View File

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

View File

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

View File

@ -2,34 +2,47 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import logging import logging
from datetime import timedelta
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.template.loader import render_to_string from django.template.loader import render_to_string
from allauth.account.models import EmailAddress from allauth.account.models import EmailAddress
from common.models import InvenTree from common.models import NotificationEntry
import InvenTree.helpers import InvenTree.helpers
import InvenTree.tasks import InvenTree.tasks
from part.models import Part import part.models
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
def notify_low_stock(part: Part): def notify_low_stock(part: part.models.Part):
""" """
Notify users who have starred a part when its stock quantity falls below the minimum threshold 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}") logger.info(f"Sending low stock notification email for {part.full_name}")
starred_users_email = EmailAddress.objects.filter(user__starred_parts__part=part) # 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 # TODO: In the future, include the part image in the email template
if len(starred_users_email) > 0: if len(emails) > 0:
logger.info(f"Notify users regarding low stock of {part.name}") logger.info(f"Notify users regarding low stock of {part.name}")
context = { context = {
# Pass the "Part" object through to the template context # Pass the "Part" object through to the template context
@ -37,22 +50,28 @@ def notify_low_stock(part: Part):
'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()), 'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()),
} }
subject = _(f'[InvenTree] {part.name} is low on stock') subject = "[InvenTree] " + _("Low stock notification")
html_message = render_to_string('email/low_stock_notification.html', context) html_message = render_to_string('email/low_stock_notification.html', context)
recipients = starred_users_email.values_list('email', flat=True) recipients = emails.values_list('email', flat=True)
InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message) 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):
def notify_low_stock_if_required(part: part.models.Part):
""" """
Check if the stock quantity has fallen below the minimum threshold of 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 If true, notify the users who have subscribed to the part
""" """
if part.is_part_low_on_stock(): # Run "up" the tree, to allow notification for "parent" parts
InvenTree.tasks.offload_task( parts = part.get_ancestors(include_self=True, ascending=True)
'part.tasks.notify_low_stock',
part for p in parts:
) if p.is_part_low_on_stock():
InvenTree.tasks.offload_task(
'part.tasks.notify_low_stock',
p
)

View File

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

View File

@ -20,15 +20,37 @@
{% include "admin_button.html" with url=url %} {% include "admin_button.html" with url=url %}
{% endif %} {% endif %}
{% if category %} {% if category %}
{% if roles.part_category.change %} {% if starred_directly %}
<button class='btn btn-outline-secondary' id='cat-edit' title='{% trans "Edit part category" %}'> <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this category" %}'>
<span class='fas fa-edit'/> <span id='category-star-icon' class='fas fa-bell icon-green'></span>
</button>
{% elif starred %}
<button type='button' class='btn btn-outline-secondary' title='{% trans "You are subscribed to notifications for this category" %}' disabled='true'>
<span class='fas fa-bell icon-green'></span>
</button>
{% else %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this category" %}'>
<span id='category-star-icon' class='fa fa-bell-slash'/>
</button> </button>
{% endif %} {% endif %}
{% if roles.part_category.delete %} {% if roles.part_category.change or roles.part_category.delete %}
<button class='btn btn-outline-secondary' id='cat-delete' title='{% trans "Delete part category" %}'> <div class='btn-group' role='group'>
<span class='fas fa-trash-alt icon-red'/> <button id='category-options' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Category Actions" %}'>
</button> <span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
{% if roles.part_category.change %}
<li><a class='dropdown-item' href='#' id='cat-edit' title='{% trans "Edit category" %}'>
<span class='fas fa-edit icon-green'></span> {% trans "Edit Category" %}
</a></li>
{% endif %}
{% if roles.part_category.delete %}
<li><a class='dropdown-item' href='#' id='cat-delete' title='{% trans "Delete category" %}'>
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Category" %}
</a></li>
{% endif %}
</ul>
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if roles.part_category.add %} {% if roles.part_category.add %}
@ -198,6 +220,14 @@
data: {{ parameters|safe }}, data: {{ parameters|safe }},
} }
); );
$("#toggle-starred").click(function() {
toggleStar({
url: '{% url "api-part-category-detail" category.pk %}',
button: '#category-star-icon'
});
});
{% endif %} {% endif %}
enableSidebar('category'); enableSidebar('category');
@ -210,7 +240,8 @@
{% else %} {% else %}
parent: null, parent: null,
{% endif %} {% endif %}
} },
allowTreeView: true,
} }
); );

View File

@ -20,13 +20,6 @@
<!-- Details Table --> <!-- Details Table -->
<table class="table table-striped table-condensed"> <table class="table table-striped table-condensed">
<col width='25'> <col width='25'>
{% if part.IPN %}
<tr>
<td><span class='fas fa-tag'></span></td>
<td>{% trans "IPN" %}</td>
<td>{{ part.IPN }}{% include "clip.html"%}</td>
</tr>
{% endif %}
<tr> <tr>
<td><span class='fas fa-shapes'></span></td> <td><span class='fas fa-shapes'></span></td>
<td>{% trans "Name" %}</td> <td>{% trans "Name" %}</td>
@ -37,6 +30,22 @@
<td>{% trans "Description" %}</td> <td>{% trans "Description" %}</td>
<td>{{ part.description }}{% include "clip.html"%}</td> <td>{{ part.description }}{% include "clip.html"%}</td>
</tr> </tr>
{% if part.category %}
<tr>
<td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Category" %}</td>
<td>
<a href='{% url "category-detail" part.category.pk %}'>{{ part.category.name }}</a>
</td>
</tr>
{% endif %}
{% if part.IPN %}
<tr>
<td><span class='fas fa-tag'></span></td>
<td>{% trans "IPN" %}</td>
<td>{{ part.IPN }}{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if part.revision %} {% if part.revision %}
<tr> <tr>
<td><span class='fas fa-code-branch'></span></td> <td><span class='fas fa-code-branch'></span></td>
@ -44,6 +53,20 @@
<td>{{ part.revision }}{% include "clip.html"%}</td> <td>{{ part.revision }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.units %}
<tr>
<td></td>
<td>{% trans "Units" %}</td>
<td>{{ part.units }}</td>
</tr>
{% endif %}
{% if part.minimum_stock %}
<tr>
<td><span class='fas fa-flag'></span></td>
<td>{% trans "Minimum stock level" %}</td>
<td>{{ part.minimum_stock }}</td>
</tr>
{% endif %}
{% if part.keywords %} {% if part.keywords %}
<tr> <tr>
<td><span class='fas fa-key'></span></td> <td><span class='fas fa-key'></span></td>
@ -64,7 +87,7 @@
<td> <td>
{{ part.creation_date }} {{ part.creation_date }}
{% if part.creation_user %} {% if part.creation_user %}
<span class='badge'>{{ part.creation_user }}</span> <span class='badge badge-right rounded-pill bg-dark'>{{ part.creation_user }}</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -79,7 +102,9 @@
<tr> <tr>
<td><span class='fas fa-search-location'></span></td> <td><span class='fas fa-search-location'></span></td>
<td>{% trans "Default Location" %}</td> <td>{% trans "Default Location" %}</td>
<td>{{ part.default_location }}</td> <td>
<a href='{% url "stock-location-detail" part.default_location.pk %}'>{{ part.default_location }}</a>
</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.default_supplier %} {% if part.default_supplier %}
@ -95,7 +120,15 @@
<div class='panel panel-hidden' id='panel-part-stock'> <div class='panel panel-hidden' id='panel-part-stock'>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Part Stock" %}</h4> <div class='d-flex flex-wrap'>
<h4>{% trans "Part Stock" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='new-stock-item' title='{% trans "Create new stock item" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
</button>
</div>
</div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% if part.is_template %} {% if part.is_template %}
@ -851,11 +884,13 @@
}); });
onPanelLoad("part-stock", function() { onPanelLoad("part-stock", function() {
$('#add-stock-item').click(function () { $('#new-stock-item').click(function () {
createNewStockItem({ createNewStockItem({
reload: true,
data: { data: {
part: {{ part.id }}, part: {{ part.id }},
{% if part.default_location %}
location: {{ part.default_location.pk }},
{% endif %}
} }
}); });
}); });
@ -883,7 +918,6 @@
$('#item-create').click(function () { $('#item-create').click(function () {
createNewStockItem({ createNewStockItem({
reload: true,
data: { data: {
part: {{ part.id }}, part: {{ part.id }},
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,9 @@ from mptt.managers import TreeManager
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta from datetime import datetime, timedelta
from InvenTree import helpers from InvenTree import helpers
import InvenTree.tasks
import common.models import common.models
import report.models import report.models
@ -41,7 +43,6 @@ from users.models import Owner
from company import models as CompanyModels from company import models as CompanyModels
from part import models as PartModels from part import models as PartModels
from part import tasks as part_tasks
class StockLocation(InvenTreeTree): class StockLocation(InvenTreeTree):
@ -455,7 +456,6 @@ class StockItem(MPTTModel):
verbose_name=_('Base Part'), verbose_name=_('Base Part'),
related_name='stock_items', help_text=_('Base part'), related_name='stock_items', help_text=_('Base part'),
limit_choices_to={ limit_choices_to={
'active': True,
'virtual': False 'virtual': False
}) })
@ -1658,16 +1658,18 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs):
Function to be executed after a StockItem object is deleted Function to be executed after a StockItem object is deleted
""" """
part_tasks.notify_low_stock_if_required(instance.part) # 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') @receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log')
def after_save_stock_item(sender, instance: StockItem, **kwargs): def after_save_stock_item(sender, instance: StockItem, **kwargs):
""" """
Hook function to be executed after StockItem object is saved/updated Hook function to be executed after StockItem object is saved/updated
""" """
part_tasks.notify_low_stock_if_required(instance.part) # Run this check in the background
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part)
class StockItemAttachment(InvenTreeAttachment): class StockItemAttachment(InvenTreeAttachment):

View File

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

View File

@ -393,7 +393,7 @@
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Last Stocktake" %}</td> <td>{% trans "Last Stocktake" %}</td>
{% if item.stocktake_date %} {% if item.stocktake_date %}
<td>{{ item.stocktake_date }} <span class='badge'>{{ item.stocktake_user }}</span></td> <td>{{ item.stocktake_date }} <span class='badge badge-right rounded-pill bg-dark'>{{ item.stocktake_user }}</span></td>
{% else %} {% else %}
<td><em>{% trans "No stocktake performed" %}</em></td> <td><em>{% trans "No stocktake performed" %}</em></td>
{% endif %} {% endif %}
@ -410,20 +410,33 @@
<td>{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}</td> <td>{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if item.owner %}
<tr>
<td><span class='fas fa-users'></span></td>
<td>{% trans "Owner" %}</td>
<td>{{ item.owner }}</td>
</tr>
{% endif %}
</table> </table>
{% endblock %} {% endblock details_right %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$("#stock-serialize").click(function() { $("#stock-serialize").click(function() {
launchModalForm(
"{% url 'stock-item-serialize' item.id %}", serializeStockItem({{ item.pk }}, {
{ reload: true,
reload: true, data: {
quantity: {{ item.quantity }},
{% if item.location %}
destination: {{ item.location.pk }},
{% elif item.part.default_location %}
destination: {{ item.part.default_location.pk }},
{% endif %}
} }
); });
}); });
$('#stock-install-in').click(function() { $('#stock-install-in').click(function() {
@ -463,22 +476,16 @@ $("#print-label").click(function() {
{% if roles.stock.change %} {% if roles.stock.change %}
$("#stock-duplicate").click(function() { $("#stock-duplicate").click(function() {
createNewStockItem({ // Duplicate a stock item
duplicateStockItem({{ item.pk }}, {
follow: true, follow: true,
data: {
copy: {{ item.id }},
}
}); });
}); });
$("#stock-edit").click(function () { $('#stock-edit').click(function() {
launchModalForm( editStockItem({{ item.pk }}, {
"{% url 'stock-item-edit' item.id %}", reload: true,
{ });
reload: true,
submit_text: '{% trans "Save" %}',
}
);
}); });
$('#stock-edit-status').click(function () { $('#stock-edit-status').click(function () {

View File

@ -140,7 +140,15 @@
<div class='panel panel-hidden' id='panel-stock'> <div class='panel panel-hidden' id='panel-stock'>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Stock Items" %}</h4> <div class='d-flex flex-wrap'>
<h4>{% trans "Stock Items" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='item-create' title='{% trans "Create new stock item" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
</button>
</div>
</div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% include "stock_table.html" %} {% include "stock_table.html" %}
@ -183,7 +191,8 @@
{% else %} {% else %}
parent: 'null', parent: 'null',
{% endif %} {% endif %}
} },
allowTreeView: true,
}); });
linkButtonsToSelection( linkButtonsToSelection(
@ -222,33 +231,21 @@
}); });
$('#location-create').click(function () { $('#location-create').click(function () {
launchModalForm("{% url 'stock-location-create' %}",
{ createStockLocation({
data: { {% if location %}
{% if location %} parent: {{ location.pk }},
location: {{ location.id }} {% endif %}
{% endif %} follow: true,
}, });
follow: true,
secondary: [
{
field: 'parent',
label: '{% trans "New Location" %}',
title: '{% trans "Create new location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
return false;
}); });
{% if location %} {% if location %}
$('#location-edit').click(function() { $('#location-edit').click(function() {
launchModalForm("{% url 'stock-location-edit' location.id %}", editStockLocation({{ location.id }}, {
{ reload: true,
reload: true });
});
return false;
}); });
$('#location-delete').click(function() { $('#location-delete').click(function() {
@ -311,12 +308,11 @@
$('#item-create').click(function () { $('#item-create').click(function () {
createNewStockItem({ createNewStockItem({
follow: true,
data: { data: {
{% if location %} {% if location %}
location: {{ location.id }} location: {{ location.id }}
{% endif %} {% endif %}
} },
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -76,6 +76,7 @@ function addHeaderAction(label, title, icon, options) {
} }
{% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %} {% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %}
{% settings_value 'HOMEPAGE_CATEGORY_STARRED' user=request.user as setting_category_starred %}
{% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %} {% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %}
{% settings_value 'HOMEPAGE_BOM_VALIDATION' user=request.user as setting_bom_validation %} {% settings_value 'HOMEPAGE_BOM_VALIDATION' user=request.user as setting_bom_validation %}
{% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %} {% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %}
@ -84,15 +85,25 @@ function addHeaderAction(label, title, icon, options) {
addHeaderTitle('{% trans "Parts" %}'); addHeaderTitle('{% trans "Parts" %}');
{% if setting_part_starred %} {% if setting_part_starred %}
addHeaderAction('starred-parts', '{% trans "Starred Parts" %}', 'fa-star'); addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bell');
loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", {
params: { params: {
"starred": true, starred: true,
}, },
name: 'starred_parts', name: 'starred_parts',
}); });
{% endif %} {% endif %}
{% if setting_category_starred %}
addHeaderAction('starred-categories', '{% trans "Subscribed Categories" %}', 'fa-bell');
loadPartCategoryTable($('#table-starred-categories'), {
params: {
starred: true,
},
name: 'starred_categories'
});
{% endif %}
{% if setting_part_latest %} {% if setting_part_latest %}
addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper'); addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper');
loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
@ -128,8 +139,7 @@ loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", {
{% to_list setting_stock_recent setting_stock_low setting_stock_depleted setting_stock_needed as settings_list_stock %} {% to_list setting_stock_recent setting_stock_low setting_stock_depleted setting_stock_needed as settings_list_stock %}
{% endif %} {% endif %}
{% if roles.stock.view and True in settings_list_stock %} {% if roles.stock.view %}
addHeaderTitle('{% trans "Stock" %}');
{% if setting_stock_recent %} {% if setting_stock_recent %}
addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock'); addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock');
@ -145,7 +155,7 @@ loadStockTable($('#table-recently-updated-stock'), {
{% endif %} {% endif %}
{% if setting_stock_low %} {% if setting_stock_low %}
addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart'); addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-flag');
loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", {
params: { params: {
low_stock: true, low_stock: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -84,6 +84,13 @@
</div> </div>
</div> </div>
<main class='col ps-md-2 pt-2'> <main class='col ps-md-2 pt-2'>
{% block alerts %}
<div class='notification-area' id='alerts'>
<!-- Div for displayed alerts -->
</div>
{% endblock %}
{% block breadcrumb_list %} {% block breadcrumb_list %}
<div class='container-fluid navigation'> <div class='container-fluid navigation'>
<nav aria-label='breadcrumb'> <nav aria-label='breadcrumb'>
@ -102,7 +109,6 @@
</div> </div>
{% include 'modals.html' %} {% include 'modals.html' %}
{% include 'about.html' %} {% include 'about.html' %}
{% include 'notification.html' %}
</div> </div>
<!-- Scripts --> <!-- Scripts -->
@ -135,9 +141,9 @@
<!-- general InvenTree --> <!-- general InvenTree -->
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<!-- dynamic javascript templates --> <!-- dynamic javascript templates -->
<script type='text/javascript' src="{% url 'inventree.js' %}"></script>
<script type='text/javascript' src="{% url 'calendar.js' %}"></script> <script type='text/javascript' src="{% url 'calendar.js' %}"></script>
<script type='text/javascript' src="{% url 'nav.js' %}"></script> <script type='text/javascript' src="{% url 'nav.js' %}"></script>
<script type='text/javascript' src="{% url 'settings.js' %}"></script> <script type='text/javascript' src="{% url 'settings.js' %}"></script>
@ -177,15 +183,13 @@ $(document).ready(function () {
inventreeDocReady(); inventreeDocReady();
showCachedAlerts();
{% if barcodes %} {% if barcodes %}
$('#barcode-scan').click(function() { $('#barcode-scan').click(function() {
barcodeScanDialog(); barcodeScanDialog();
}); });
{% endif %} {% endif %}
moment.locale('{{request.LANGUAGE_CODE}}'); moment.locale('{{ request.LANGUAGE_CODE }}');
}); });
</script> </script>

View File

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

View File

@ -8,22 +8,25 @@
{% if link %} {% if link %}
<p>{% trans "Click on the following link to view this part" %}: <a href="{{ link }}">{{ link }}</a></p> <p>{% trans "Click on the following link to view this part" %}: <a href="{{ link }}">{{ link }}</a></p>
{% endif %} {% endif %}
{% endblock %} {% endblock title %}
{% block subtitle %}
<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 %}
{% block body %} {% block body %}
<tr style="height: 3rem; border-bottom: 1px solid"> <tr style="height: 3rem; border-bottom: 1px solid">
<th>{% trans "Part Name" %}</th> <th>{% trans "Part" %}</th>
<th>{% trans "Available Quantity" %}</th> <th>{% trans "Total Stock" %}</th>
<th>{% trans "Available" %}</th>
<th>{% trans "Minimum Quantity" %}</th> <th>{% trans "Minimum Quantity" %}</th>
</tr> </tr>
<tr style="height: 3rem"> <tr style="height: 3rem">
<td style="text-align: center;">{{ part.full_name }}</td> <td style="text-align: center;">{{ part.full_name }}</td>
<td style="text-align: center;">{{ part.total_stock }}</td> <td style="text-align: center;">{% decimal part.total_stock %}</td>
<td style="text-align: center;">{{ part.minimum_stock }}</td> <td style="text-align: center;">{% decimal part.available_stock %}</td>
<td style="text-align: center;">{% decimal part.minimum_stock %}</td>
</tr> </tr>
{% endblock %} {% 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

@ -2,8 +2,6 @@
{% load inventree_extras %} {% load inventree_extras %}
/* globals /* globals
renderErrorMessage,
showAlertDialog,
*/ */
/* exported /* exported
@ -68,6 +66,8 @@ function inventreeGet(url, filters={}, options={}) {
options.error({ options.error({
error: thrownError error: thrownError
}); });
} else {
showApiError(xhr, url);
} }
} }
}); });
@ -104,6 +104,8 @@ function inventreeFormDataUpload(url, data, options={}) {
if (options.error) { if (options.error) {
options.error(xhr, status, error); options.error(xhr, status, error);
} else {
showApiError(xhr, url);
} }
} }
}); });
@ -139,6 +141,8 @@ function inventreePut(url, data={}, options={}) {
} else { } else {
console.error(`Error on ${method} to '${url}' - STATUS ${xhr.status}`); console.error(`Error on ${method} to '${url}' - STATUS ${xhr.status}`);
console.error(thrownError); console.error(thrownError);
showApiError(xhr, url);
} }
}, },
complete: function(xhr, status) { complete: function(xhr, status) {
@ -162,8 +166,10 @@ function inventreeDelete(url, options={}) {
return inventreePut(url, {}, options); return inventreePut(url, {}, options);
} }
/*
function showApiError(xhr) { * Display a notification with error information
*/
function showApiError(xhr, url) {
var title = null; var title = null;
var message = null; var message = null;
@ -208,7 +214,11 @@ function showApiError(xhr) {
} }
message += '<hr>'; message += '<hr>';
message += renderErrorMessage(xhr); message += `URL: ${url}`;
showAlertDialog(title, message); showMessage(title, {
style: 'danger',
icon: 'fas fa-server icon-red',
details: message,
});
} }

View File

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

View File

@ -339,7 +339,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
break; break;
default: default:
$(opts.modal).modal('hide'); $(opts.modal).modal('hide');
showApiError(xhr); showApiError(xhr, opts.url);
break; break;
} }
} }
@ -1527,7 +1527,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
break; break;
default: default:
$(opts.modal).modal('hide'); $(opts.modal).modal('hide');
showApiError(xhr); showApiError(xhr, opts.url);
break; break;
} }
} }

View File

@ -25,7 +25,12 @@
*/ */
/* exported /* exported
setFormGroupVisibility clearFormInput,
disableFormInput,
enableFormInput,
hideFormInput,
setFormGroupVisibility,
showFormInput,
*/ */
/** /**
@ -113,6 +118,10 @@ function canDelete(OPTIONS) {
*/ */
function getApiEndpointOptions(url, callback) { function getApiEndpointOptions(url, callback) {
if (!url) {
return;
}
// Return the ajax request object // Return the ajax request object
$.ajax({ $.ajax({
url: url, url: url,
@ -123,9 +132,10 @@ function getApiEndpointOptions(url, callback) {
json: 'application/json', json: 'application/json',
}, },
success: callback, success: callback,
error: function() { error: function(xhr) {
// TODO: Handle error // TODO: Handle error
console.log(`ERROR in getApiEndpointOptions at '${url}'`); console.log(`ERROR in getApiEndpointOptions at '${url}'`);
showApiError(xhr, url);
} }
}); });
} }
@ -181,6 +191,7 @@ function constructChangeForm(fields, options) {
// Request existing data from the API endpoint // Request existing data from the API endpoint
$.ajax({ $.ajax({
url: options.url, url: options.url,
data: options.params || {},
type: 'GET', type: 'GET',
contentType: 'application/json', contentType: 'application/json',
dataType: 'json', dataType: 'json',
@ -196,15 +207,28 @@ function constructChangeForm(fields, options) {
fields[field].value = data[field]; fields[field].value = data[field];
} }
} }
// An optional function can be provided to process the returned results,
// before they are rendered to the form
if (options.processResults) {
var processed = options.processResults(data, fields, options);
// If the processResults function returns data, it will be stored
if (processed) {
data = processed;
}
}
// Store the entire data object // Store the entire data object
options.instance = data; options.instance = data;
constructFormBody(fields, options); constructFormBody(fields, options);
}, },
error: function() { error: function(xhr) {
// TODO: Handle error here // TODO: Handle error here
console.log(`ERROR in constructChangeForm at '${options.url}'`); console.log(`ERROR in constructChangeForm at '${options.url}'`);
showApiError(xhr, options.url);
} }
}); });
} }
@ -241,9 +265,11 @@ function constructDeleteForm(fields, options) {
constructFormBody(fields, options); constructFormBody(fields, options);
}, },
error: function() { error: function(xhr) {
// TODO: Handle error here // TODO: Handle error here
console.log(`ERROR in constructDeleteForm at '${options.url}`); console.log(`ERROR in constructDeleteForm at '${options.url}`);
showApiError(xhr, options.url);
} }
}); });
} }
@ -708,7 +734,9 @@ function submitFormData(fields, options) {
break; break;
default: default:
$(options.modal).modal('hide'); $(options.modal).modal('hide');
showApiError(xhr);
console.log(`upload error at ${options.url}`);
showApiError(xhr, options.url);
break; break;
} }
} }
@ -885,19 +913,19 @@ function handleFormSuccess(response, options) {
// Display any messages // Display any messages
if (response && response.success) { if (response && response.success) {
showAlertOrCache('alert-success', response.success, cache); showAlertOrCache(response.success, cache, {style: 'success'});
} }
if (response && response.info) { if (response && response.info) {
showAlertOrCache('alert-info', response.info, cache); showAlertOrCache(response.info, cache, {style: 'info'});
} }
if (response && response.warning) { if (response && response.warning) {
showAlertOrCache('alert-warning', response.warning, cache); showAlertOrCache(response.warning, cache, {style: 'warning'});
} }
if (response && response.danger) { if (response && response.danger) {
showAlertOrCache('alert-danger', response.danger, cache); showAlertOrCache(response.danger, cache, {style: 'danger'});
} }
if (options.onSuccess) { if (options.onSuccess) {
@ -1236,6 +1264,35 @@ function initializeGroups(fields, options) {
} }
} }
// Clear a form input
function clearFormInput(name, options) {
updateFieldValue(name, null, {}, options);
}
// Disable a form input
function disableFormInput(name, options) {
$(options.modal).find(`#id_${name}`).prop('disabled', true);
}
// Enable a form input
function enableFormInput(name, options) {
$(options.modal).find(`#id_${name}`).prop('disabled', false);
}
// Hide a form input
function hideFormInput(name, options) {
$(options.modal).find(`#div_id_${name}`).hide();
}
// Show a form input
function showFormInput(name, options) {
$(options.modal).find(`#div_id_${name}`).show();
}
// Hide a form group // Hide a form group
function hideFormGroup(group, options) { function hideFormGroup(group, options) {
$(options.modal).find(`#form-panel-${group}`).hide(); $(options.modal).find(`#form-panel-${group}`).hide();

View File

@ -399,19 +399,19 @@ function afterForm(response, options) {
// Display any messages // Display any messages
if (response.success) { if (response.success) {
showAlertOrCache('alert-success', response.success, cache); showAlertOrCache(response.success, cache, {style: 'success'});
} }
if (response.info) { if (response.info) {
showAlertOrCache('alert-info', response.info, cache); showAlertOrCache(response.info, cache, {style: 'info'});
} }
if (response.warning) { if (response.warning) {
showAlertOrCache('alert-warning', response.warning, cache); showAlertOrCache(response.warning, cache, {style: 'warning'});
} }
if (response.danger) { if (response.danger) {
showAlertOrCache('alert-danger', response.danger, cache); showAlertOrCache(response.danger, cache, {style: 'danger'});
} }
// Was a callback provided? // Was a callback provided?

View File

@ -555,7 +555,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
break; break;
default: default:
$(opts.modal).modal('hide'); $(opts.modal).modal('hide');
showApiError(xhr); showApiError(xhr, opts.url);
break; break;
} }
} }

View File

@ -373,24 +373,23 @@ function duplicatePart(pk, options={}) {
} }
/* Toggle the 'starred' status of a part.
* Performs AJAX queries and updates the display on the button.
*
* options:
* - button: ID of the button (default = '#part-star-icon')
* - URL: API url of the object
* - user: pk of the user
*/
function toggleStar(options) { function toggleStar(options) {
/* Toggle the 'starred' status of a part.
* Performs AJAX queries and updates the display on the button.
*
* options:
* - button: ID of the button (default = '#part-star-icon')
* - part: pk of the part object
* - user: pk of the user
*/
var url = `/api/part/${options.part}/`; inventreeGet(options.url, {}, {
inventreeGet(url, {}, {
success: function(response) { success: function(response) {
var starred = response.starred; var starred = response.starred;
inventreePut( inventreePut(
url, options.url,
{ {
starred: !starred, starred: !starred,
}, },
@ -398,9 +397,19 @@ function toggleStar(options) {
method: 'PATCH', method: 'PATCH',
success: function(response) { success: function(response) {
if (response.starred) { if (response.starred) {
$(options.button).addClass('icon-yellow'); $(options.button).removeClass('fa fa-bell-slash').addClass('fas fa-bell icon-green');
$(options.button).attr('title', '{% trans "You are subscribed to notifications for this item" %}');
showMessage('{% trans "You have subscribed to notifications for this item" %}', {
style: 'success',
});
} else { } else {
$(options.button).removeClass('icon-yellow'); $(options.button).removeClass('fas fa-bell icon-green').addClass('fa fa-bell-slash');
$(options.button).attr('title', '{% trans "Subscribe to notifications for this item" %}');
showMessage('{% trans "You have unsubscribed to notifications for this item" %}', {
style: 'warning',
});
} }
} }
} }
@ -410,12 +419,12 @@ function toggleStar(options) {
} }
function partStockLabel(part) { function partStockLabel(part, options={}) {
if (part.in_stock) { if (part.in_stock) {
return `<span class='badge rounded-pill bg-success'>{% trans "Stock" %}: ${part.in_stock}</span>`; return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Stock" %}: ${part.in_stock}</span>`;
} else { } else {
return `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`; return `<span class='badge rounded-pill bg-danger ${options.classes}'>{% trans "No Stock" %}</span>`;
} }
} }
@ -443,7 +452,7 @@ function makePartIcons(part) {
} }
if (part.starred) { if (part.starred) {
html += makeIconBadge('fa-star', '{% trans "Starred part" %}'); html += makeIconBadge('fa-bell icon-green', '{% trans "Subscribed part" %}');
} }
if (part.salable) { if (part.salable) {
@ -451,7 +460,7 @@ function makePartIcons(part) {
} }
if (!part.active) { if (!part.active) {
html += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Inactive" %}</span>`; html += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Inactive" %}</span> `;
} }
return html; return html;
@ -1133,8 +1142,10 @@ function loadPartTable(table, url, options={}) {
} }
/*
* Display a table of part categories
*/
function loadPartCategoryTable(table, options) { function loadPartCategoryTable(table, options) {
/* Display a table of part categories */
var params = options.params || {}; var params = options.params || {};
@ -1148,6 +1159,13 @@ function loadPartCategoryTable(table, options) {
filters = loadTableFilters(filterKey); filters = loadTableFilters(filterKey);
} }
var tree_view = options.allowTreeView && inventreeLoad('category-tree-view') == 1;
if (tree_view) {
params.cascade = true;
}
var original = {}; var original = {};
for (var key in params) { for (var key in params) {
@ -1157,15 +1175,13 @@ function loadPartCategoryTable(table, options) {
setupFilterList(filterKey, table, filterListElement); setupFilterList(filterKey, table, filterListElement);
var tree_view = inventreeLoad('category-tree-view') == 1;
table.inventreeTable({ table.inventreeTable({
treeEnable: tree_view, treeEnable: tree_view,
rootParentId: options.params.parent, rootParentId: tree_view ? options.params.parent : null,
uniqueId: 'pk', uniqueId: 'pk',
idField: 'pk', idField: 'pk',
treeShowField: 'name', treeShowField: 'name',
parentIdField: 'parent', parentIdField: tree_view ? 'parent' : null,
method: 'get', method: 'get',
url: options.url || '{% url "api-part-category-list" %}', url: options.url || '{% url "api-part-category-list" %}',
queryParams: filters, queryParams: filters,
@ -1176,7 +1192,7 @@ function loadPartCategoryTable(table, options) {
name: 'category', name: 'category',
original: original, original: original,
showColumns: true, showColumns: true,
buttons: [ buttons: options.allowTreeView ? [
{ {
icon: 'fas fa-bars', icon: 'fas fa-bars',
attributes: { attributes: {
@ -1215,28 +1231,31 @@ function loadPartCategoryTable(table, options) {
); );
} }
} }
], ] : [],
onPostBody: function() { onPostBody: function() {
tree_view = inventreeLoad('category-tree-view') == 1; if (options.allowTreeView) {
if (tree_view) { tree_view = inventreeLoad('category-tree-view') == 1;
$('#view-category-list').removeClass('btn-secondary').addClass('btn-outline-secondary'); if (tree_view) {
$('#view-category-tree').removeClass('btn-outline-secondary').addClass('btn-secondary');
$('#view-category-list').removeClass('btn-secondary').addClass('btn-outline-secondary');
table.treegrid({ $('#view-category-tree').removeClass('btn-outline-secondary').addClass('btn-secondary');
treeColumn: 0,
onChange: function() { table.treegrid({
table.bootstrapTable('resetView'); treeColumn: 0,
}, onChange: function() {
onExpand: function() { table.bootstrapTable('resetView');
},
} onExpand: function() {
});
} else { }
$('#view-category-tree').removeClass('btn-secondary').addClass('btn-outline-secondary'); });
$('#view-category-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); } else {
$('#view-category-tree').removeClass('btn-secondary').addClass('btn-outline-secondary');
$('#view-category-list').removeClass('btn-outline-secondary').addClass('btn-secondary');
}
} }
}, },
columns: [ columns: [
@ -1253,10 +1272,17 @@ function loadPartCategoryTable(table, options) {
switchable: true, switchable: true,
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
return renderLink(
var html = renderLink(
value, value,
`/part/category/${row.pk}/` `/part/category/${row.pk}/`
); );
if (row.starred) {
html += makeIconBadge('fa-bell icon-green', '{% trans "Subscribed category" %}');
}
return html;
} }
}, },
{ {

View File

@ -4,9 +4,6 @@
/* globals /* globals
attachSelect, attachSelect,
enableField,
clearField,
clearFieldOptions,
closeModal, closeModal,
constructField, constructField,
constructFormBody, constructFormBody,
@ -33,10 +30,8 @@
printStockItemLabels, printStockItemLabels,
printTestReports, printTestReports,
renderLink, renderLink,
reloadFieldOptions,
scanItemsIntoLocation, scanItemsIntoLocation,
showAlertDialog, showAlertDialog,
setFieldValue,
setupFilterList, setupFilterList,
showApiError, showApiError,
stockStatusDisplay, stockStatusDisplay,
@ -44,6 +39,10 @@
/* exported /* exported
createNewStockItem, createNewStockItem,
createStockLocation,
duplicateStockItem,
editStockItem,
editStockLocation,
exportStock, exportStock,
loadInstalledInTable, loadInstalledInTable,
loadStockLocationTable, loadStockLocationTable,
@ -51,20 +50,318 @@
loadStockTestResultsTable, loadStockTestResultsTable,
loadStockTrackingTable, loadStockTrackingTable,
loadTableFilters, loadTableFilters,
locationFields,
removeStockRow, removeStockRow,
serializeStockItem,
stockItemFields,
stockLocationFields,
stockStatusCodes, stockStatusCodes,
*/ */
function locationFields() { /*
return { * Launches a modal form to serialize a particular StockItem
*/
function serializeStockItem(pk, options={}) {
var url = `/api/stock/${pk}/serialize/`;
options.method = 'POST';
options.title = '{% trans "Serialize Stock Item" %}';
options.fields = {
quantity: {},
serial_numbers: {
icon: 'fa-hashtag',
},
destination: {
icon: 'fa-sitemap',
},
notes: {},
};
constructForm(url, options);
}
function stockLocationFields(options={}) {
var fields = {
parent: { parent: {
help_text: '{% trans "Parent stock location" %}', help_text: '{% trans "Parent stock location" %}',
}, },
name: {}, name: {},
description: {}, description: {},
}; };
if (options.parent) {
fields.parent.value = options.parent;
}
return fields;
}
/*
* Launch an API form to edit a stock location
*/
function editStockLocation(pk, options={}) {
var url = `/api/stock/location/${pk}/`;
options.fields = stockLocationFields(options);
constructForm(url, options);
}
/*
* Launch an API form to create a new stock location
*/
function createStockLocation(options={}) {
var url = '{% url "api-location-list" %}';
options.method = 'POST';
options.fields = stockLocationFields(options);
options.title = '{% trans "New Stock Location" %}';
constructForm(url, options);
}
function stockItemFields(options={}) {
var fields = {
part: {
// Hide the part field unless we are "creating" a new stock item
hidden: !options.create,
onSelect: function(data, field, opts) {
// Callback when a new "part" is selected
// If we are "creating" a new stock item,
// change the available fields based on the part properties
if (options.create) {
// If a "trackable" part is selected, enable serial number field
if (data.trackable) {
enableFormInput('serial_numbers', opts);
// showFormInput('serial_numbers', opts);
} else {
clearFormInput('serial_numbers', opts);
disableFormInput('serial_numbers', opts);
}
// Enable / disable fields based on purchaseable status
if (data.purchaseable) {
enableFormInput('supplier_part', opts);
enableFormInput('purchase_price', opts);
enableFormInput('purchase_price_currency', opts);
} else {
clearFormInput('supplier_part', opts);
clearFormInput('purchase_price', opts);
disableFormInput('supplier_part', opts);
disableFormInput('purchase_price', opts);
disableFormInput('purchase_price_currency', opts);
}
}
}
},
supplier_part: {
icon: 'fa-building',
filters: {
part_detail: true,
supplier_detail: true,
},
adjustFilters: function(query, opts) {
var part = getFormFieldValue('part', {}, opts);
if (part) {
query.part = part;
}
return query;
}
},
location: {
icon: 'fa-sitemap',
},
quantity: {
help_text: '{% trans "Enter initial quantity for this stock item" %}',
},
serial_numbers: {
icon: 'fa-hashtag',
type: 'string',
label: '{% trans "Serial Numbers" %}',
help_text: '{% trans "Enter serial numbers for new stock (or leave blank)" %}',
required: false,
},
serial: {
icon: 'fa-hashtag',
},
status: {},
expiry_date: {},
batch: {},
purchase_price: {
icon: 'fa-dollar-sign',
},
purchase_price_currency: {},
packaging: {
icon: 'fa-box',
},
link: {
icon: 'fa-link',
},
owner: {},
delete_on_deplete: {},
};
if (options.create) {
// Use special "serial numbers" field when creating a new stock item
delete fields['serial'];
} else {
// These fields cannot be edited once the stock item has been created
delete fields['serial_numbers'];
delete fields['quantity'];
delete fields['location'];
}
// Remove stock expiry fields if feature is not enabled
if (!global_settings.STOCK_ENABLE_EXPIRY) {
delete fields['expiry_date'];
}
// Remove ownership field if feature is not enanbled
if (!global_settings.STOCK_OWNERSHIP_CONTROL) {
delete fields['owner'];
}
return fields;
}
function stockItemGroups(options={}) {
return {
};
}
/*
* Launch a modal form to duplicate a given StockItem
*/
function duplicateStockItem(pk, options) {
// First, we need the StockItem informatino
inventreeGet(`/api/stock/${pk}/`, {}, {
success: function(data) {
// Do not duplicate the serial number
delete data['serial'];
options.data = data;
options.create = true;
options.fields = stockItemFields(options);
options.groups = stockItemGroups(options);
options.method = 'POST';
options.title = '{% trans "Duplicate Stock Item" %}';
constructForm('{% url "api-stock-list" %}', options);
}
});
}
/*
* Launch a modal form to edit a given StockItem
*/
function editStockItem(pk, options={}) {
var url = `/api/stock/${pk}/`;
options.create = false;
options.fields = stockItemFields(options);
options.groups = stockItemGroups(options);
options.title = '{% trans "Edit Stock Item" %}';
// Query parameters for retrieving stock item data
options.params = {
part_detail: true,
supplier_part_detail: true,
};
// Augment the rendered form when we receive information about the StockItem
options.processResults = function(data, fields, options) {
if (data.part_detail.trackable) {
delete options.fields.delete_on_deplete;
} else {
// Remove serial number field if part is not trackable
delete options.fields.serial;
}
// Remove pricing fields if part is not purchaseable
if (!data.part_detail.purchaseable) {
delete options.fields.supplier_part;
delete options.fields.purchase_price;
delete options.fields.purchase_price_currency;
}
};
constructForm(url, options);
}
/*
* Launch an API form to contsruct a new stock item
*/
function createNewStockItem(options={}) {
var url = '{% url "api-stock-list" %}';
options.title = '{% trans "New Stock Item" %}';
options.method = 'POST';
options.create = true;
options.fields = stockItemFields(options);
options.groups = stockItemGroups(options);
if (!options.onSuccess) {
options.onSuccess = function(response) {
// If a single stock item has been created, follow it!
if (response.pk) {
var url = `/stock/item/${response.pk}/`;
addCachedAlert('{% trans "Created new stock item" %}', {
icon: 'fas fa-boxes',
});
window.location.href = url;
} else {
// Multiple stock items have been created (i.e. serialized stock)
var details = `
<br>{% trans "Quantity" %}: ${response.quantity}
<br>{% trans "Serial Numbers" %}: ${response.serial_numbers}
`;
showMessage('{% trans "Created multiple stock items" %}', {
icon: 'fas fa-boxes',
details: details,
});
var table = options.table || '#stock-table';
// Reload the table
$(table).bootstrapTable('refresh');
}
};
}
constructForm(url, options);
} }
@ -427,7 +724,7 @@ function adjustStock(action, items, options={}) {
break; break;
default: default:
$(opts.modal).modal('hide'); $(opts.modal).modal('hide');
showApiError(xhr); showApiError(xhr, opts.url);
break; break;
} }
} }
@ -1416,13 +1713,22 @@ function loadStockTable(table, options) {
}); });
} }
/*
* Display a table of stock locations
*/
function loadStockLocationTable(table, options) { function loadStockLocationTable(table, options) {
/* Display a table of stock locations */
var params = options.params || {}; var params = options.params || {};
var filterListElement = options.filterList || '#filter-list-location'; var filterListElement = options.filterList || '#filter-list-location';
var tree_view = options.allowTreeView && inventreeLoad('location-tree-view') == 1;
if (tree_view) {
params.cascade = true;
}
var filters = {}; var filters = {};
var filterKey = options.filterKey || options.name || 'location'; var filterKey = options.filterKey || options.name || 'location';
@ -1443,15 +1749,13 @@ function loadStockLocationTable(table, options) {
filters[key] = params[key]; filters[key] = params[key];
} }
var tree_view = inventreeLoad('location-tree-view') == 1;
table.inventreeTable({ table.inventreeTable({
treeEnable: tree_view, treeEnable: tree_view,
rootParentId: options.params.parent, rootParentId: tree_view ? options.params.parent : null,
uniqueId: 'pk', uniqueId: 'pk',
idField: 'pk', idField: 'pk',
treeShowField: 'name', treeShowField: 'name',
parentIdField: 'parent', parentIdField: tree_view ? 'parent' : null,
disablePagination: tree_view, disablePagination: tree_view,
sidePagination: tree_view ? 'client' : 'server', sidePagination: tree_view ? 'client' : 'server',
serverSort: !tree_view, serverSort: !tree_view,
@ -1465,28 +1769,31 @@ function loadStockLocationTable(table, options) {
showColumns: true, showColumns: true,
onPostBody: function() { onPostBody: function() {
tree_view = inventreeLoad('location-tree-view') == 1; if (options.allowTreeView) {
if (tree_view) { tree_view = inventreeLoad('location-tree-view') == 1;
$('#view-location-list').removeClass('btn-secondary').addClass('btn-outline-secondary'); if (tree_view) {
$('#view-location-tree').removeClass('btn-outline-secondary').addClass('btn-secondary');
$('#view-location-list').removeClass('btn-secondary').addClass('btn-outline-secondary');
table.treegrid({ $('#view-location-tree').removeClass('btn-outline-secondary').addClass('btn-secondary');
treeColumn: 1,
onChange: function() { table.treegrid({
table.bootstrapTable('resetView'); treeColumn: 1,
}, onChange: function() {
onExpand: function() { table.bootstrapTable('resetView');
},
} onExpand: function() {
});
} else { }
$('#view-location-tree').removeClass('btn-secondary').addClass('btn-outline-secondary'); });
$('#view-location-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); } else {
$('#view-location-tree').removeClass('btn-secondary').addClass('btn-outline-secondary');
$('#view-location-list').removeClass('btn-outline-secondary').addClass('btn-secondary');
}
} }
}, },
buttons: [ buttons: options.allowTreeView ? [
{ {
icon: 'fas fa-bars', icon: 'fas fa-bars',
attributes: { attributes: {
@ -1525,7 +1832,7 @@ function loadStockLocationTable(table, options) {
); );
} }
} }
], ] : [],
columns: [ columns: [
{ {
checkbox: true, checkbox: true,
@ -1800,79 +2107,6 @@ function loadStockTrackingTable(table, options) {
} }
function createNewStockItem(options) {
/* Launch a modal form to create a new stock item.
*
* This is really just a helper function which calls launchModalForm,
* but it does get called a lot, so here we are ...
*/
// Add in some funky options
options.callback = [
{
field: 'part',
action: function(value) {
if (!value) {
// No part chosen
clearFieldOptions('supplier_part');
enableField('serial_numbers', false);
enableField('purchase_price_0', false);
enableField('purchase_price_1', false);
return;
}
// Reload options for supplier part
reloadFieldOptions(
'supplier_part',
{
url: '{% url "api-supplier-part-list" %}',
params: {
part: value,
pretty: true,
},
text: function(item) {
return item.pretty_name;
}
}
);
// Request part information from the server
inventreeGet(
`/api/part/${value}/`, {},
{
success: function(response) {
// Disable serial number field if the part is not trackable
enableField('serial_numbers', response.trackable);
clearField('serial_numbers');
enableField('purchase_price_0', response.purchaseable);
enableField('purchase_price_1', response.purchaseable);
// Populate the expiry date
if (response.default_expiry <= 0) {
// No expiry date
clearField('expiry_date');
} else {
var expiry = moment().add(response.default_expiry, 'days');
setFieldValue('expiry_date', expiry.format('YYYY-MM-DD'));
}
}
}
);
}
},
];
launchModalForm('{% url "stock-item-create" %}', options);
}
function loadInstalledInTable(table, options) { function loadInstalledInTable(table, options) {
/* /*
* Display a table showing the stock items which are installed in this stock item. * Display a table showing the stock items which are installed in this stock item.

View File

@ -103,6 +103,10 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Include subcategories" %}', title: '{% trans "Include subcategories" %}',
description: '{% trans "Include subcategories" %}', description: '{% trans "Include subcategories" %}',
}, },
starred: {
type: 'bool',
title: '{% trans "Subscribed" %}',
},
}; };
} }
@ -368,7 +372,7 @@ function getAvailableTableFilters(tableKey) {
}, },
starred: { starred: {
type: 'bool', type: 'bool',
title: '{% trans "Starred" %}', title: '{% trans "Subscribed" %}',
}, },
salable: { salable: {
type: 'bool', type: 'bool',

View File

@ -1,18 +0,0 @@
<div class='notification-area'>
<div class="alert alert-success alert-dismissable" id="alert-success">
<a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
<div class='alert-msg'>Success alert</div>
</div>
<div class='alert alert-info alert-dismissable' id='alert-info'>
<a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
<div class='alert-msg'>Info alert</div>
</div>
<div class='alert alert-warning alert-dismissable' id='alert-warning'>
<a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
<div class='alert-msg'>Warning alert</div>
</div>
<div class='alert alert-danger alert-dismissable' id='alert-danger'>
<a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
<div class='alert-msg'>Danger alert</div>
</div>
</div>

View File

@ -10,17 +10,10 @@
<div id='{{ prefix }}button-toolbar'> <div id='{{ prefix }}button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'> <div class='btn-group' role='group'>
<button class='btn btn-outline-secondary' id='stock-export' title='{% trans "Export Stock Information" %}'> <button class='btn btn-outline-secondary' id='stock-export' title='{% trans "Export Stock Information" %}'>
<span class='fas fa-download'></span> <span class='fas fa-download'></span>
</button> </button>
{% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %}
{% if not read_only and not prevent_new_stock and roles.stock.add %}
<button class="btn btn-success" id='item-create' title='{% trans "New Stock Item" %}'>
<span class='fas fa-plus-circle'></span>
</button>
{% endif %}
{% if barcodes %} {% if barcodes %}
<!-- Barcode actions menu --> <!-- Barcode actions menu -->
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
@ -46,7 +39,7 @@
</div> </div>
{% if not read_only %} {% if not read_only %}
{% if roles.stock.change or roles.stock.delete %} {% if roles.stock.change or roles.stock.delete %}
<div class="btn-group"> <div class="btn-group" role="group">
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" title='{% trans "Stock Options" %}'> <button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" title='{% trans "Stock Options" %}'>
<span class='fas fa-boxes'></span> <span class="caret"></span> <span class='fas fa-boxes'></span> <span class="caret"></span>
</button> </button>
@ -66,7 +59,6 @@
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %}
{% include "filter_list.html" with id="stock" %} {% include "filter_list.html" with id="stock" %}
</div> </div>
</div> </div>

View File

@ -80,6 +80,7 @@ class RuleSet(models.Model):
'part_category': [ 'part_category': [
'part_partcategory', 'part_partcategory',
'part_partcategoryparametertemplate', 'part_partcategoryparametertemplate',
'part_partcategorystar',
], ],
'part': [ 'part': [
'part_part', 'part_part',
@ -93,6 +94,7 @@ class RuleSet(models.Model):
'part_partparameter', 'part_partparameter',
'part_partrelated', 'part_partrelated',
'part_partstar', 'part_partstar',
'part_partcategorystar',
'company_supplierpart', 'company_supplierpart',
'company_manufacturerpart', 'company_manufacturerpart',
'company_manufacturerpartparameter', 'company_manufacturerpartparameter',
@ -152,6 +154,7 @@ class RuleSet(models.Model):
'common_colortheme', 'common_colortheme',
'common_inventreesetting', 'common_inventreesetting',
'common_inventreeusersetting', 'common_inventreeusersetting',
'common_notificationentry',
'company_contact', 'company_contact',
'users_owner', 'users_owner',

View File

@ -13,6 +13,21 @@ version: "3.8"
# specified in the "volumes" section at the end of this file. # specified in the "volumes" section at the end of this file.
# This path determines where the InvenTree data will be stored! # This path determines where the InvenTree data will be stored!
# #
#
# InvenTree Image Versions
# ------------------------
# By default, this docker-compose script targets the STABLE version of InvenTree,
# image: inventree/inventree:stable
#
# To run the LATEST (development) version of InvenTree, change the target image to:
# image: inventree/inventree:latest
#
# Alternatively, you could target a specific tagged release version with (for example):
# image: inventree/inventree:0.5.3
#
# NOTE: If you change the target image, ensure it is the same for the following containers:
# - inventree-server
# - inventree-worker
services: services:
# Database service # Database service
@ -40,8 +55,7 @@ services:
inventree-server: inventree-server:
container_name: inventree-server container_name: inventree-server
# If you wish to specify a particular InvenTree version, do so here # If you wish to specify a particular InvenTree version, do so here
# e.g. image: inventree/inventree:0.5.2 image: inventree/inventree:stable
image: inventree/inventree:latest
expose: expose:
- 8000 - 8000
depends_on: depends_on:
@ -58,8 +72,7 @@ services:
inventree-worker: inventree-worker:
container_name: inventree-worker container_name: inventree-worker
# If you wish to specify a particular InvenTree version, do so here # If you wish to specify a particular InvenTree version, do so here
# e.g. image: inventree/inventree:0.5.2 image: inventree/inventree:stable
image: inventree/inventree:latest
command: invoke worker command: invoke worker
depends_on: depends_on:
- inventree-db - inventree-db

View File

@ -286,6 +286,7 @@ def content_excludes():
"users.owner", "users.owner",
"exchange.rate", "exchange.rate",
"exchange.exchangebackend", "exchange.exchangebackend",
"common.notificationentry",
] ]
output = "" output = ""