mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
a6030d4cc8
@ -22,7 +22,28 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
|
|
||||||
def _is_true(x):
|
def _is_true(x):
|
||||||
return x in [True, "True", "true", "Y", "y", "1"]
|
# Shortcut function to determine if a value "looks" like a boolean
|
||||||
|
return str(x).lower() in ['1', 'y', 'yes', 't', 'true']
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(environment_var, backup_val, default_value=None):
|
||||||
|
"""
|
||||||
|
Helper function for retrieving a configuration setting value
|
||||||
|
|
||||||
|
- First preference is to look for the environment variable
|
||||||
|
- Second preference is to look for the value of the settings file
|
||||||
|
- Third preference is the default value
|
||||||
|
"""
|
||||||
|
|
||||||
|
val = os.getenv(environment_var)
|
||||||
|
|
||||||
|
if val is not None:
|
||||||
|
return val
|
||||||
|
|
||||||
|
if backup_val is not None:
|
||||||
|
return backup_val
|
||||||
|
|
||||||
|
return default_value
|
||||||
|
|
||||||
|
|
||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||||
@ -39,10 +60,17 @@ with open(cfg_filename, 'r') as cfg:
|
|||||||
|
|
||||||
# Default action is to run the system in Debug mode
|
# Default action is to run the system in Debug mode
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = _is_true(os.getenv("INVENTREE_DEBUG", CONFIG.get("debug", True)))
|
DEBUG = _is_true(get_setting(
|
||||||
|
'INVENTREE_DEBUG',
|
||||||
|
CONFIG.get('debug', True)
|
||||||
|
))
|
||||||
|
|
||||||
# Configure logging settings
|
# Configure logging settings
|
||||||
log_level = CONFIG.get('log_level', 'DEBUG').upper()
|
log_level = get_setting(
|
||||||
|
'INVENTREE_LOG_LEVEL',
|
||||||
|
CONFIG.get('log_level', 'DEBUG')
|
||||||
|
)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=log_level,
|
level=log_level,
|
||||||
format="%(asctime)s %(levelname)s %(message)s",
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
@ -75,6 +103,7 @@ if os.getenv("INVENTREE_SECRET_KEY"):
|
|||||||
else:
|
else:
|
||||||
# Secret key passed in by file location
|
# Secret key passed in by file location
|
||||||
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
|
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
|
||||||
|
|
||||||
if key_file:
|
if key_file:
|
||||||
if os.path.isfile(key_file):
|
if os.path.isfile(key_file):
|
||||||
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY_FILE")
|
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY_FILE")
|
||||||
@ -112,7 +141,12 @@ if cors_opt:
|
|||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
# The filesystem location for served static files
|
# The filesystem location for served static files
|
||||||
STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static')))
|
STATIC_ROOT = os.path.abspath(
|
||||||
|
get_setting(
|
||||||
|
'INVENTREE_STATIC_ROOT',
|
||||||
|
CONFIG.get('static_root', os.path.join(BASE_DIR, 'static'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
os.path.join(BASE_DIR, 'InvenTree', 'static'),
|
os.path.join(BASE_DIR, 'InvenTree', 'static'),
|
||||||
@ -125,7 +159,12 @@ STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes')
|
|||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
|
|
||||||
# The filesystem location for served static files
|
# The filesystem location for served static files
|
||||||
MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media')))
|
MEDIA_ROOT = os.path.abspath(
|
||||||
|
get_setting(
|
||||||
|
'INVENTREE_MEDIA_ROOT',
|
||||||
|
CONFIG.get('media_root', os.path.join(BASE_DIR, 'media'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
logger.info("InvenTree running in DEBUG mode")
|
logger.info("InvenTree running in DEBUG mode")
|
||||||
@ -133,30 +172,6 @@ if DEBUG:
|
|||||||
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||||
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||||
|
|
||||||
# Does the user wish to use the sentry.io integration?
|
|
||||||
sentry_opts = CONFIG.get('sentry', {})
|
|
||||||
|
|
||||||
if sentry_opts.get('enabled', False):
|
|
||||||
|
|
||||||
logger.info("Configuring sentry.io integration")
|
|
||||||
|
|
||||||
dsn = sentry_opts.get('dsn', None)
|
|
||||||
|
|
||||||
if dsn is not None:
|
|
||||||
# Try to import required modules (exit if not installed)
|
|
||||||
try:
|
|
||||||
import sentry_sdk
|
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
|
||||||
|
|
||||||
sentry_sdk.init(dsn=dsn, integrations=[DjangoIntegration()], send_default_pii=True)
|
|
||||||
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
logger.error("sentry_sdk module not found. Install using 'pip install sentry-sdk'")
|
|
||||||
sys.exit(-1)
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning("Sentry.io DSN not specified in config file")
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
@ -430,16 +445,17 @@ if not type(EXTRA_URL_SCHEMES) in [list]:
|
|||||||
EXTRA_URL_SCHEMES = []
|
EXTRA_URL_SCHEMES = []
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/1.10/topics/i18n/
|
# https://docs.djangoproject.com/en/dev/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = CONFIG.get('language', 'en-us')
|
LANGUAGE_CODE = CONFIG.get('language', 'en-us')
|
||||||
|
|
||||||
# If a new language translation is supported, it must be added here
|
# If a new language translation is supported, it must be added here
|
||||||
LANGUAGES = [
|
LANGUAGES = [
|
||||||
('en', _('English')),
|
('en', _('English')),
|
||||||
('de', _('German')),
|
|
||||||
('fr', _('French')),
|
('fr', _('French')),
|
||||||
|
('de', _('German')),
|
||||||
('pk', _('Polish')),
|
('pk', _('Polish')),
|
||||||
|
('tr', _('Turkish')),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Currencies available for use
|
# Currencies available for use
|
||||||
@ -491,10 +507,15 @@ CRISPY_TEMPLATE_PACK = 'bootstrap3'
|
|||||||
# Use database transactions when importing / exporting data
|
# Use database transactions when importing / exporting data
|
||||||
IMPORT_EXPORT_USE_TRANSACTIONS = True
|
IMPORT_EXPORT_USE_TRANSACTIONS = True
|
||||||
|
|
||||||
|
BACKUP_DIR = get_setting(
|
||||||
|
'INVENTREE_BACKUP_DIR',
|
||||||
|
CONFIG.get('backup_dir', tempfile.gettempdir()),
|
||||||
|
)
|
||||||
|
|
||||||
# Settings for dbbsettings app
|
# Settings for dbbsettings app
|
||||||
DBBACKUP_STORAGE = 'django.core.files.storage.FileSystemStorage'
|
DBBACKUP_STORAGE = 'django.core.files.storage.FileSystemStorage'
|
||||||
DBBACKUP_STORAGE_OPTIONS = {
|
DBBACKUP_STORAGE_OPTIONS = {
|
||||||
'location': CONFIG.get('backup_dir', tempfile.gettempdir()),
|
'location': BACKUP_DIR,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Internal IP addresses allowed to see the debug toolbar
|
# Internal IP addresses allowed to see the debug toolbar
|
||||||
|
@ -29,6 +29,7 @@ from stock.api import stock_api_urls
|
|||||||
from build.api import build_api_urls
|
from build.api import build_api_urls
|
||||||
from order.api import order_api_urls
|
from order.api import order_api_urls
|
||||||
from label.api import label_api_urls
|
from label.api import label_api_urls
|
||||||
|
from report.api import report_api_urls
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
@ -60,6 +61,7 @@ apipatterns = [
|
|||||||
url(r'^build/', include(build_api_urls)),
|
url(r'^build/', include(build_api_urls)),
|
||||||
url(r'^order/', include(order_api_urls)),
|
url(r'^order/', include(order_api_urls)),
|
||||||
url(r'^label/', include(label_api_urls)),
|
url(r'^label/', include(label_api_urls)),
|
||||||
|
url(r'^report/', include(report_api_urls)),
|
||||||
|
|
||||||
# User URLs
|
# User URLs
|
||||||
url(r'^user/', include(user_urls)),
|
url(r'^user/', include(user_urls)),
|
||||||
@ -101,6 +103,7 @@ dynamic_javascript_urls = [
|
|||||||
url(r'^order.js', DynamicJsView.as_view(template_name='js/order.js'), name='order.js'),
|
url(r'^order.js', DynamicJsView.as_view(template_name='js/order.js'), name='order.js'),
|
||||||
url(r'^part.js', DynamicJsView.as_view(template_name='js/part.js'), name='part.js'),
|
url(r'^part.js', DynamicJsView.as_view(template_name='js/part.js'), name='part.js'),
|
||||||
url(r'^label.js', DynamicJsView.as_view(template_name='js/label.js'), name='label.js'),
|
url(r'^label.js', DynamicJsView.as_view(template_name='js/label.js'), name='label.js'),
|
||||||
|
url(r'^report.js', DynamicJsView.as_view(template_name='js/report.js'), name='report.js'),
|
||||||
url(r'^stock.js', DynamicJsView.as_view(template_name='js/stock.js'), name='stock.js'),
|
url(r'^stock.js', DynamicJsView.as_view(template_name='js/stock.js'), name='stock.js'),
|
||||||
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.js'), name='table_filters.js'),
|
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.js'), name='table_filters.js'),
|
||||||
]
|
]
|
||||||
|
@ -196,6 +196,13 @@ class InvenTreeSetting(models.Model):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'STOCK_OWNERSHIP_CONTROL': {
|
||||||
|
'name': _('Stock Ownership Control'),
|
||||||
|
'description': _('Enable ownership control over stock locations and items'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
|
||||||
'BUILDORDER_REFERENCE_PREFIX': {
|
'BUILDORDER_REFERENCE_PREFIX': {
|
||||||
'name': _('Build Order Reference Prefix'),
|
'name': _('Build Order Reference Prefix'),
|
||||||
'description': _('Prefix value for build order reference'),
|
'description': _('Prefix value for build order reference'),
|
||||||
|
@ -107,13 +107,6 @@ static_root: '../inventree_static'
|
|||||||
# If unspecified, the local user's temp directory will be used
|
# If unspecified, the local user's temp directory will be used
|
||||||
#backup_dir: '/home/inventree/backup/'
|
#backup_dir: '/home/inventree/backup/'
|
||||||
|
|
||||||
# Sentry.io integration
|
|
||||||
# If you have a sentry.io account, it can be used to log server errors
|
|
||||||
# Ensure sentry_sdk is installed by running 'pip install sentry-sdk'
|
|
||||||
sentry:
|
|
||||||
enabled: False
|
|
||||||
# dsn: add-your-sentry-dsn-here
|
|
||||||
|
|
||||||
# LaTeX report rendering
|
# LaTeX report rendering
|
||||||
# InvenTree uses the django-tex plugin to enable LaTeX report rendering
|
# InvenTree uses the django-tex plugin to enable LaTeX report rendering
|
||||||
# Ref: https://pypi.org/project/django-tex/
|
# Ref: https://pypi.org/project/django-tex/
|
||||||
|
@ -310,7 +310,7 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = StockLocationLabel.objects.all()
|
queryset = StockLocationLabel.objects.all()
|
||||||
seiralizers_class = StockLocationLabelSerializer
|
seiralizer_class = StockLocationLabelSerializer
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
|
||||||
|
70
InvenTree/label/test_api.py
Normal file
70
InvenTree/label/test_api.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# Tests for labels
|
||||||
|
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportTests(APITestCase):
|
||||||
|
"""
|
||||||
|
Tests for the StockItem TestReport templates
|
||||||
|
"""
|
||||||
|
|
||||||
|
fixtures = [
|
||||||
|
'category',
|
||||||
|
'part',
|
||||||
|
'location',
|
||||||
|
'stock',
|
||||||
|
]
|
||||||
|
|
||||||
|
list_url = reverse('api-stockitem-testreport-list')
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
user = get_user_model()
|
||||||
|
|
||||||
|
self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
|
|
||||||
|
self.user.is_staff = True
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
self.client.login(username='testuser', password='password')
|
||||||
|
|
||||||
|
def do_list(self, filters={}):
|
||||||
|
|
||||||
|
response = self.client.get(self.list_url, filters, format='json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
|
||||||
|
def test_list(self):
|
||||||
|
|
||||||
|
response = self.do_list()
|
||||||
|
|
||||||
|
# TODO - Add some report templates to the fixtures
|
||||||
|
self.assertEqual(len(response), 0)
|
||||||
|
|
||||||
|
# TODO - Add some tests to this response
|
||||||
|
response = self.do_list(
|
||||||
|
{
|
||||||
|
'item': 10,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO - Add some tests to this response
|
||||||
|
response = self.do_list(
|
||||||
|
{
|
||||||
|
'item': 100000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO - Add some tests to this response
|
||||||
|
response = self.do_list(
|
||||||
|
{
|
||||||
|
'items': [10, 11, 12],
|
||||||
|
}
|
||||||
|
)
|
@ -1,4 +1,4 @@
|
|||||||
# Tests for Part Parameters
|
# Tests for labels
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1856,9 +1856,6 @@ class BomItem(models.Model):
|
|||||||
self.clean()
|
self.clean()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
return reverse('bom-item-detail', kwargs={'pk': self.id})
|
|
||||||
|
|
||||||
# A link to the parent part
|
# A link to the parent part
|
||||||
# Each part will get a reverse lookup field 'bom_items'
|
# Each part will get a reverse lookup field 'bom_items'
|
||||||
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items',
|
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items',
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
{% extends "modal_delete_form.html" %}
|
{% extends "modal_delete_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
|
|
||||||
Are you sure you want to delete this BOM item?
|
{% trans "Are you sure you want to delete this BOM item?" %}
|
||||||
<br>
|
<br>
|
||||||
Deleting this entry will remove the BOM row from the following part:
|
{% trans "Deleting this entry will remove the BOM row from the following part" %}:
|
||||||
|
|
||||||
<ul class='list-group'>
|
<ul class='list-group'>
|
||||||
<li class='list-group-item'>
|
<li class='list-group-item'>
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<h3>BOM Item</h3>
|
|
||||||
<table class="table table-striped">
|
|
||||||
<tr><td>Parent</td><td><a href="{% url 'part-bom' item.part.id %}">{{ item.part.full_name }}</a></td></tr>
|
|
||||||
<tr><td>Child</td><td><a href="{% url 'part-used-in' item.sub_part.id %}">{{ item.sub_part.full_name }}</a></td></tr>
|
|
||||||
<tr><td>Quantity</td><td>{{ item.quantity }}</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class='container-fluid'>
|
|
||||||
<a href="{% url 'bom-item-edit' item.id %}"><button class="btn btn-info">Edit BOM item</button></a>
|
|
||||||
|
|
||||||
<a href="{% url 'bom-item-delete' item.id %}"><button class="btn btn-danger">Delete BOM item</button></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@ -63,11 +63,11 @@
|
|||||||
<span class='fas fa-file-download'></span> {% trans "Export" %}
|
<span class='fas fa-file-download'></span> {% trans "Export" %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
<div class='filter-list' id='filter-list-bom'>
|
<div class='filter-list' id='filter-list-bom'>
|
||||||
<!-- Empty div (will be filled out with avilable BOM filters) -->
|
<!-- Empty div (will be filled out with avilable BOM filters) -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
|
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
|
||||||
</table>
|
</table>
|
||||||
@ -179,8 +179,8 @@
|
|||||||
secondary: [
|
secondary: [
|
||||||
{
|
{
|
||||||
field: 'sub_part',
|
field: 'sub_part',
|
||||||
label: 'New Part',
|
label: '{% trans "New Part" %}',
|
||||||
title: 'Create New Part',
|
title: '{% trans "Create New Part" %}',
|
||||||
url: "{% url 'part-create' %}",
|
url: "{% url 'part-create' %}",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
@ -49,14 +49,14 @@
|
|||||||
{% for row in bom_rows %}
|
{% for row in bom_rows %}
|
||||||
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-name='{{ row.part_name }}' part-description='{{ row.description }}' part-select='#select_part_{{ row.index }}'>
|
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-name='{{ row.part_name }}' part-description='{{ row.description }}' part-select='#select_part_{{ row.index }}'>
|
||||||
<td>
|
<td>
|
||||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ forloop.counter }}' style='display: inline; float: right;' title='Remove row'>
|
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ forloop.counter }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
|
||||||
<span row_id='{{ forloop.counter }}' class='fas fa-trash-alt icon-red'></span>
|
<span row_id='{{ forloop.counter }}' class='fas fa-trash-alt icon-red'></span>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td>{% add row.index 1 %}</td>
|
<td>{% add row.index 1 %}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class='btn btn-default btn-create' onClick='newPartFromBomWizard()' id='new_part_row_{{ row.index }}' title='Create new part' type='button'>
|
<button class='btn btn-default btn-create' onClick='newPartFromBomWizard()' id='new_part_row_{{ row.index }}' title='{% trans "Create new part" %}' type='button'>
|
||||||
<span row_id='{{ row.index }}' class='fas fa-plus icon-green'/>
|
<span row_id='{{ row.index }}' class='fas fa-plus icon-green'/>
|
||||||
</button>
|
</button>
|
||||||
<select class='select bomselect' id='select_part_{{ row.index }}' name='part_{{ row.index }}'>
|
<select class='select bomselect' id='select_part_{{ row.index }}' name='part_{{ row.index }}'>
|
||||||
|
@ -126,11 +126,11 @@
|
|||||||
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
|
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class='filter-list' id='filter-list-parts'>
|
<div class='filter-list' id='filter-list-parts'>
|
||||||
<!-- Empty div -->
|
<!-- Empty div -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% block category_tables %}
|
{% block category_tables %}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{% extends "modal_form.html" %}
|
{% extends "modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
|
|
||||||
@ -10,8 +11,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if matches %}
|
{% if matches %}
|
||||||
<b>Possible Matching Parts</b>
|
<b>{% trans "Possible Matching Parts" %}</b>
|
||||||
<p>The new part may be a duplicate of these existing parts:</p>
|
<p>{% trans "The new part may be a duplicate of these existing parts" %}:</p>
|
||||||
<ul class='list-group'>
|
<ul class='list-group'>
|
||||||
{% for match in matches %}
|
{% for match in matches %}
|
||||||
<li class='list-group-item list-group-item-condensed'>
|
<li class='list-group-item list-group-item-condensed'>
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
{% extends "modal_form.html" %}
|
{% extends "modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
|
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
<div class='alert alert-info alert-block'>
|
<div class='alert alert-info alert-block'>
|
||||||
<b>Create new part variant</b><br>
|
<b>{% trans "Create new part variant" %}</b><br>
|
||||||
Create a new variant of template <i>'{{ part.full_name }}'</i>.
|
{% trans "Create a new variant of template" %} <i>'{{ part.full_name }}'</i>.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -145,3 +145,22 @@ def get_color_theme_css(username):
|
|||||||
inventree_css_static_url = os.path.join(settings.STATIC_URL, inventree_css_sheet)
|
inventree_css_static_url = os.path.join(settings.STATIC_URL, inventree_css_sheet)
|
||||||
|
|
||||||
return inventree_css_static_url
|
return inventree_css_static_url
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def authorized_owners(group):
|
||||||
|
""" Return authorized owners """
|
||||||
|
|
||||||
|
owners = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for owner in group.get_related_owners(include_group=True):
|
||||||
|
owners.append(owner.owner)
|
||||||
|
except AttributeError:
|
||||||
|
# group is None
|
||||||
|
pass
|
||||||
|
except TypeError:
|
||||||
|
# group.get_users returns None
|
||||||
|
pass
|
||||||
|
|
||||||
|
return owners
|
||||||
|
@ -99,8 +99,6 @@ part_category_urls = [
|
|||||||
part_bom_urls = [
|
part_bom_urls = [
|
||||||
url(r'^edit/?', views.BomItemEdit.as_view(), name='bom-item-edit'),
|
url(r'^edit/?', views.BomItemEdit.as_view(), name='bom-item-edit'),
|
||||||
url('^delete/?', views.BomItemDelete.as_view(), name='bom-item-delete'),
|
url('^delete/?', views.BomItemDelete.as_view(), name='bom-item-delete'),
|
||||||
|
|
||||||
url(r'^.*$', views.BomItemDetail.as_view(), name='bom-item-detail'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# URL list for part web interface
|
# URL list for part web interface
|
||||||
|
@ -2411,15 +2411,6 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
|
|||||||
return self.object
|
return self.object
|
||||||
|
|
||||||
|
|
||||||
class BomItemDetail(InvenTreeRoleMixin, DetailView):
|
|
||||||
""" Detail view for BomItem """
|
|
||||||
context_object_name = 'item'
|
|
||||||
queryset = BomItem.objects.all()
|
|
||||||
template_name = 'part/bom-detail.html'
|
|
||||||
|
|
||||||
role_required = 'part.view'
|
|
||||||
|
|
||||||
|
|
||||||
class BomItemCreate(AjaxCreateView):
|
class BomItemCreate(AjaxCreateView):
|
||||||
""" Create view for making a new BomItem object """
|
""" Create view for making a new BomItem object """
|
||||||
model = BomItem
|
model = BomItem
|
||||||
|
208
InvenTree/report/api.py
Normal file
208
InvenTree/report/api.py
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
|
||||||
|
from rest_framework import generics, filters
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
import InvenTree.helpers
|
||||||
|
|
||||||
|
from stock.models import StockItem
|
||||||
|
|
||||||
|
from .models import TestReport
|
||||||
|
from .serializers import TestReportSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class ReportListView(generics.ListAPIView):
|
||||||
|
"""
|
||||||
|
Generic API class for report templates
|
||||||
|
"""
|
||||||
|
|
||||||
|
filter_backends = [
|
||||||
|
DjangoFilterBackend,
|
||||||
|
filters.SearchFilter,
|
||||||
|
]
|
||||||
|
|
||||||
|
filter_fields = [
|
||||||
|
'enabled',
|
||||||
|
]
|
||||||
|
|
||||||
|
search_fields = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemReportMixin:
|
||||||
|
"""
|
||||||
|
Mixin for extracting stock items from query params
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_items(self):
|
||||||
|
"""
|
||||||
|
Return a list of requested stock items
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = []
|
||||||
|
|
||||||
|
params = self.request.query_params
|
||||||
|
|
||||||
|
if 'items[]' in params:
|
||||||
|
items = params.getlist('items[]', [])
|
||||||
|
elif 'item' in params:
|
||||||
|
items = [params.get('item', None)]
|
||||||
|
|
||||||
|
if type(items) not in [list, tuple]:
|
||||||
|
item = [items]
|
||||||
|
|
||||||
|
valid_ids = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
valid_ids.append(int(item))
|
||||||
|
except (ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# List of StockItems which match provided values
|
||||||
|
valid_items = StockItem.objects.filter(pk__in=valid_ids)
|
||||||
|
|
||||||
|
return valid_items
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemTestReportList(ReportListView, StockItemReportMixin):
|
||||||
|
"""
|
||||||
|
API endpoint for viewing list of TestReport objects.
|
||||||
|
|
||||||
|
Filterable by:
|
||||||
|
|
||||||
|
- enabled: Filter by enabled / disabled status
|
||||||
|
- item: Filter by single stock item
|
||||||
|
- items: Filter by list of stock items
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = TestReport.objects.all()
|
||||||
|
serializer_class = TestReportSerializer
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset):
|
||||||
|
|
||||||
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
|
# List of StockItem objects to match against
|
||||||
|
items = self.get_items()
|
||||||
|
|
||||||
|
if len(items) > 0:
|
||||||
|
"""
|
||||||
|
We wish to filter by stock items.
|
||||||
|
|
||||||
|
We need to compare the 'filters' string of each report,
|
||||||
|
and see if it matches against each of the specified stock items.
|
||||||
|
|
||||||
|
TODO: In the future, perhaps there is a way to make this more efficient.
|
||||||
|
"""
|
||||||
|
|
||||||
|
valid_report_ids = set()
|
||||||
|
|
||||||
|
for report in queryset.all():
|
||||||
|
|
||||||
|
matches = True
|
||||||
|
|
||||||
|
# Filter string defined for the report object
|
||||||
|
filters = InvenTree.helpers.validateFilterString(report.filters)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item_query = StockItem.objects.filter(pk=item.pk)
|
||||||
|
|
||||||
|
if not item_query.filter(**filters).exists():
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
valid_report_ids.add(report.pk)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Reduce queryset to only valid matches
|
||||||
|
queryset = queryset.filter(pk__in=[pk for pk in valid_report_ids])
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemTestReportDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for a single TestReport object
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = TestReport.objects.all()
|
||||||
|
serializer_class = TestReportSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin):
|
||||||
|
"""
|
||||||
|
API endpoint for printing a TestReport object
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = TestReport.objects.all()
|
||||||
|
serializer_class = TestReportSerializer
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Check if valid stock item(s) have been provided.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = self.get_items()
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
# No valid items provided, return an error message
|
||||||
|
data = {
|
||||||
|
'error': _('Must provide valid StockItem(s)')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(data, status=400)
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
|
||||||
|
# Merge one or more PDF files into a single download
|
||||||
|
for item in items:
|
||||||
|
report = self.get_object()
|
||||||
|
report.stock_item = item
|
||||||
|
|
||||||
|
outputs.append(report.render(request))
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
|
||||||
|
if len(outputs) > 1:
|
||||||
|
# If more than one output is generated, merge them into a single file
|
||||||
|
for output in outputs:
|
||||||
|
doc = output.get_document()
|
||||||
|
for page in doc.pages:
|
||||||
|
pages.append(page)
|
||||||
|
|
||||||
|
pdf = outputs[0].get_document().copy(pages).write_pdf()
|
||||||
|
else:
|
||||||
|
pdf = outputs[0].get_document().write_pdf()
|
||||||
|
|
||||||
|
return InvenTree.helpers.DownloadFile(
|
||||||
|
pdf,
|
||||||
|
'test_report.pdf',
|
||||||
|
content_type='application/pdf'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
report_api_urls = [
|
||||||
|
|
||||||
|
# Stock item test reports
|
||||||
|
url(r'test/', include([
|
||||||
|
# Detail views
|
||||||
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
|
url(r'print/?', StockItemTestReportPrint.as_view(), name='api-stockitem-testreport-print'),
|
||||||
|
url(r'^.*$', StockItemTestReportDetail.as_view(), name='api-stockitem-testreport-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
|
# List view
|
||||||
|
url(r'^.*$', StockItemTestReportList.as_view(), name='api-stockitem-testreport-list'),
|
||||||
|
])),
|
||||||
|
]
|
23
InvenTree/report/serializers.py
Normal file
23
InvenTree/report/serializers.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
|
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||||
|
|
||||||
|
from .models import TestReport
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportSerializer(InvenTreeModelSerializer):
|
||||||
|
|
||||||
|
template = InvenTreeAttachmentSerializerField(required=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TestReport
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'template',
|
||||||
|
'filters',
|
||||||
|
'enabled',
|
||||||
|
]
|
@ -28,8 +28,10 @@ def manually_translate_file(filename, save=False):
|
|||||||
print("For each missing translation:")
|
print("For each missing translation:")
|
||||||
print("a) Directly enter a new tranlation in the target language")
|
print("a) Directly enter a new tranlation in the target language")
|
||||||
print("b) Leave empty to skip")
|
print("b) Leave empty to skip")
|
||||||
|
print("c) Press Ctrl+C to exit")
|
||||||
|
|
||||||
input("Press <ENTER> to continue")
|
print("-------------------------")
|
||||||
|
input("Press <ENTER> to start")
|
||||||
print("")
|
print("")
|
||||||
|
|
||||||
with open(filename, 'r') as f:
|
with open(filename, 'r') as f:
|
||||||
@ -58,7 +60,10 @@ def manually_translate_file(filename, save=False):
|
|||||||
print("Source:", source_line)
|
print("Source:", source_line)
|
||||||
print("Enter translation for {t}".format(t=msgid))
|
print("Enter translation for {t}".format(t=msgid))
|
||||||
|
|
||||||
|
try:
|
||||||
translation = str(input(">"))
|
translation = str(input(">"))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
break
|
||||||
|
|
||||||
if translation and len(translation) > 0:
|
if translation and len(translation) > 0:
|
||||||
# Update the line with the new translation
|
# Update the line with the new translation
|
||||||
@ -71,7 +76,7 @@ def manually_translate_file(filename, save=False):
|
|||||||
output_file.writelines(out)
|
output_file.writelines(out)
|
||||||
|
|
||||||
print("Translation done: written to", filename)
|
print("Translation done: written to", filename)
|
||||||
print("Run 'make translate' to rebuild translation data")
|
print("Run 'invoke translate' to rebuild translation data")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
68
InvenTree/script/translation_stats.py
Normal file
68
InvenTree/script/translation_stats.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
This script calculates translation coverage for various languages
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_coverage(filename):
|
||||||
|
"""
|
||||||
|
Calculate translation coverage for a .po file
|
||||||
|
"""
|
||||||
|
|
||||||
|
with open(filename, 'r') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
lines_count = 0
|
||||||
|
lines_covered = 0
|
||||||
|
lines_uncovered = 0
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
|
||||||
|
if line.startswith("msgid "):
|
||||||
|
lines_count += 1
|
||||||
|
|
||||||
|
elif line.startswith("msgstr"):
|
||||||
|
if line.startswith('msgstr ""') or line.startswith("msgstr ''"):
|
||||||
|
lines_uncovered += 1
|
||||||
|
else:
|
||||||
|
lines_covered += 1
|
||||||
|
|
||||||
|
# Return stats for the file
|
||||||
|
return (lines_count, lines_covered, lines_uncovered)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
|
||||||
|
MY_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
LC_DIR = os.path.abspath(os.path.join(MY_DIR, '..', 'locale'))
|
||||||
|
|
||||||
|
locales = {}
|
||||||
|
|
||||||
|
print("InvenTree translation coverage:")
|
||||||
|
|
||||||
|
for locale in os.listdir(LC_DIR):
|
||||||
|
path = os.path.join(LC_DIR, locale)
|
||||||
|
if os.path.exists(path) and os.path.isdir(path):
|
||||||
|
|
||||||
|
locale_file = os.path.join(path, 'LC_MESSAGES', 'django.po')
|
||||||
|
|
||||||
|
if os.path.exists(locale_file) and os.path.isfile(locale_file):
|
||||||
|
locales[locale] = locale_file
|
||||||
|
|
||||||
|
print("-" * 16)
|
||||||
|
|
||||||
|
for locale in locales.keys():
|
||||||
|
locale_file = locales[locale]
|
||||||
|
stats = calculate_coverage(locale_file)
|
||||||
|
|
||||||
|
(total, covered, uncovered) = stats
|
||||||
|
|
||||||
|
if total > 0:
|
||||||
|
percentage = int(covered / total * 100)
|
||||||
|
else:
|
||||||
|
percentage = 0
|
||||||
|
|
||||||
|
print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
|
||||||
|
|
||||||
|
print("-" * 16)
|
@ -25,6 +25,18 @@
|
|||||||
lft: 0
|
lft: 0
|
||||||
rght: 0
|
rght: 0
|
||||||
|
|
||||||
|
# Capacitor C_22N_0805 in 'Office'
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 11
|
||||||
|
fields:
|
||||||
|
part: 5
|
||||||
|
location: 4
|
||||||
|
quantity: 666
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
# 1234 2K2 resistors in 'Drawer_1'
|
# 1234 2K2 resistors in 'Drawer_1'
|
||||||
- model: stock.stockitem
|
- model: stock.stockitem
|
||||||
pk: 1234
|
pk: 1234
|
||||||
|
@ -90,7 +90,8 @@ class EditStockLocationForm(HelperForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'name',
|
'name',
|
||||||
'parent',
|
'parent',
|
||||||
'description'
|
'description',
|
||||||
|
'owner',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -138,6 +139,7 @@ class CreateStockItemForm(HelperForm):
|
|||||||
'link',
|
'link',
|
||||||
'delete_on_deplete',
|
'delete_on_deplete',
|
||||||
'status',
|
'status',
|
||||||
|
'owner',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Custom clean to prevent complex StockItem.clean() logic from running (yet)
|
# Custom clean to prevent complex StockItem.clean() logic from running (yet)
|
||||||
@ -414,6 +416,7 @@ class EditStockItemForm(HelperForm):
|
|||||||
'purchase_price',
|
'purchase_price',
|
||||||
'link',
|
'link',
|
||||||
'delete_on_deplete',
|
'delete_on_deplete',
|
||||||
|
'owner',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
25
InvenTree/stock/migrations/0057_stock_location_item_owner.py
Normal file
25
InvenTree/stock/migrations/0057_stock_location_item_owner.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2021-01-11 21:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0005_owner_model'),
|
||||||
|
('stock', '0056_stockitem_expiry_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='stockitem',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Select Owner', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='users.Owner'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='stocklocation',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Select Owner', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_locations', to='users.Owner'),
|
||||||
|
),
|
||||||
|
]
|
@ -38,6 +38,8 @@ from InvenTree.status_codes import StockStatus
|
|||||||
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@ -48,6 +50,10 @@ class StockLocation(InvenTreeTree):
|
|||||||
Stock locations can be heirarchical as required
|
Stock locations can be heirarchical as required
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
owner = models.ForeignKey(Owner, on_delete=models.SET_NULL, blank=True, null=True,
|
||||||
|
help_text='Select Owner',
|
||||||
|
related_name='stock_locations')
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('stock-location-detail', kwargs={'pk': self.id})
|
return reverse('stock-location-detail', kwargs={'pk': self.id})
|
||||||
|
|
||||||
@ -489,6 +495,10 @@ class StockItem(MPTTModel):
|
|||||||
help_text=_('Single unit purchase price at time of purchase'),
|
help_text=_('Single unit purchase price at time of purchase'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
owner = models.ForeignKey(Owner, on_delete=models.SET_NULL, blank=True, null=True,
|
||||||
|
help_text='Select Owner',
|
||||||
|
related_name='stock_items')
|
||||||
|
|
||||||
def is_stale(self):
|
def is_stale(self):
|
||||||
"""
|
"""
|
||||||
Returns True if this Stock item is "stale".
|
Returns True if this Stock item is "stale".
|
||||||
|
@ -8,10 +8,17 @@
|
|||||||
|
|
||||||
{% include "stock/tabs.html" with tab="tracking" %}
|
{% include "stock/tabs.html" with tab="tracking" %}
|
||||||
|
|
||||||
|
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
|
||||||
|
{% if owner_control.value == "True" %}
|
||||||
|
{% authorized_owners item.owner as owners %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h4>{% trans "Stock Tracking Information" %}</h4>
|
<h4>{% trans "Stock Tracking Information" %}</h4>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
{% if roles.stock.change %}
|
<!-- Check permissions and owner -->
|
||||||
|
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners %}
|
||||||
|
{% if roles.stock.change and not item.is_building %}
|
||||||
<div id='table-toolbar'>
|
<div id='table-toolbar'>
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>
|
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>
|
||||||
@ -20,6 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
<table class='table table-condensed table-striped' id='track-table' data-toolbar='#table-toolbar'>
|
<table class='table table-condensed table-striped' id='track-table' data-toolbar='#table-toolbar'>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
@ -15,6 +15,17 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
{% block pre_content %}
|
{% block pre_content %}
|
||||||
{% include 'stock/loc_link.html' with location=item.location %}
|
{% include 'stock/loc_link.html' with location=item.location %}
|
||||||
|
|
||||||
|
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
|
||||||
|
{% if owner_control.value == "True" %}
|
||||||
|
{% authorized_owners item.owner as owners %}
|
||||||
|
|
||||||
|
{% if not user in owners and not user.is_superuser %}
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
{% trans "You are not in the list of owners of this item. This stock item cannot be edited." %}<br>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if item.is_building %}
|
{% if item.is_building %}
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
{% trans "This stock item is in production and cannot be edited." %}<br>
|
{% trans "This stock item is in production and cannot be edited." %}<br>
|
||||||
@ -68,6 +79,12 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block page_data %}
|
{% block page_data %}
|
||||||
|
|
||||||
|
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
|
||||||
|
{% if owner_control.value == "True" %}
|
||||||
|
{% authorized_owners item.owner as owners %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h3>
|
<h3>
|
||||||
{% trans "Stock Item" %}
|
{% trans "Stock Item" %}
|
||||||
{% if item.is_expired %}
|
{% if item.is_expired %}
|
||||||
@ -120,7 +137,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
<!-- Document / label menu -->
|
<!-- Document / label menu -->
|
||||||
{% if item.has_labels or item.has_test_reports %}
|
{% if item.has_labels or item.has_test_reports %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='document-options' title='{% trans "Document actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-file-alt'></span> <span class='caret'></span></button>
|
<button id='document-options' title='{% trans "Printing actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-print'></span> <span class='caret'></span></button>
|
||||||
<ul class='dropdown-menu' role='menu'>
|
<ul class='dropdown-menu' role='menu'>
|
||||||
{% if item.has_labels %}
|
{% if item.has_labels %}
|
||||||
<li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
<li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||||
@ -132,6 +149,8 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Stock adjustment menu -->
|
<!-- Stock adjustment menu -->
|
||||||
|
<!-- Check permissions and owner -->
|
||||||
|
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}
|
||||||
{% if roles.stock.change and not item.is_building %}
|
{% if roles.stock.change and not item.is_building %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='stock-actions' title='{% trans "Stock adjustment actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
|
<button id='stock-actions' title='{% trans "Stock adjustment actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
|
||||||
@ -181,6 +200,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -394,12 +414,7 @@ $('#stock-uninstall').click(function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("#stock-test-report").click(function() {
|
$("#stock-test-report").click(function() {
|
||||||
launchModalForm(
|
printTestReports([{{ item.pk }}]);
|
||||||
"{% url 'stock-item-test-report-select' item.id %}",
|
|
||||||
{
|
|
||||||
follow: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#print-label").click(function() {
|
$("#print-label").click(function() {
|
||||||
|
@ -50,14 +50,9 @@ function reloadTable() {
|
|||||||
//$("#test-result-table").bootstrapTable("refresh");
|
//$("#test-result-table").bootstrapTable("refresh");
|
||||||
}
|
}
|
||||||
|
|
||||||
{% if item.part.has_test_report_templates %}
|
{% if item.has_test_reports %}
|
||||||
$("#test-report").click(function() {
|
$("#test-report").click(function() {
|
||||||
launchModalForm(
|
printTestReports([{{ item.pk }}]);
|
||||||
"{% url 'stock-item-test-report-select' item.id %}",
|
|
||||||
{
|
|
||||||
follow: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -1,8 +1,20 @@
|
|||||||
{% extends "stock/stock_app_base.html" %}
|
{% extends "stock/stock_app_base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load inventree_extras %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
|
||||||
|
{% if owner_control.value == "True" %}
|
||||||
|
{% authorized_owners location.owner as owners %}
|
||||||
|
|
||||||
|
{% if location and not user in owners and not user.is_superuser %}
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
{% trans "You are not in the list of owners of this location. This stock location cannot be edited." %}<br>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class='row'>
|
<div class='row'>
|
||||||
<div class='col-sm-6'>
|
<div class='col-sm-6'>
|
||||||
{% if location %}
|
{% if location %}
|
||||||
@ -18,11 +30,13 @@
|
|||||||
<p>{% trans "All stock items" %}</p>
|
<p>{% trans "All stock items" %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class='btn-group action-buttons' role='group'>
|
<div class='btn-group action-buttons' role='group'>
|
||||||
|
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser or not location %}
|
||||||
{% if roles.stock_location.add %}
|
{% if roles.stock_location.add %}
|
||||||
<button class='btn btn-default' id='location-create' title='{% trans "Create new stock location" %}'>
|
<button class='btn btn-default' id='location-create' title='{% trans "Create new stock location" %}'>
|
||||||
<span class='fas fa-plus-circle icon-green'/>
|
<span class='fas fa-plus-circle icon-green'/>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
<!-- Barcode actions menu -->
|
<!-- Barcode actions menu -->
|
||||||
{% if location %}
|
{% if location %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
@ -33,6 +47,8 @@
|
|||||||
<li><a href='#' id='barcode-check-in'><span class='fas fa-arrow-right'></span> {% trans "Check-in Items" %}</a></li>
|
<li><a href='#' id='barcode-check-in'><span class='fas fa-arrow-right'></span> {% trans "Check-in Items" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Check permissions and owner -->
|
||||||
|
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}
|
||||||
{% if roles.stock.change %}
|
{% if roles.stock.change %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
|
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
|
||||||
@ -47,13 +63,14 @@
|
|||||||
<button id='location-actions' title='{% trans "Location actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle="dropdown"><span class='fas fa-sitemap'></span> <span class='caret'></span></button>
|
<button id='location-actions' title='{% trans "Location actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle="dropdown"><span class='fas fa-sitemap'></span> <span class='caret'></span></button>
|
||||||
<ul class='dropdown-menu' role='menu'>
|
<ul class='dropdown-menu' role='menu'>
|
||||||
<li><a href='#' id='location-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit location" %}</a></li>
|
<li><a href='#' id='location-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit location" %}</a></li>
|
||||||
{% if roles.stock_location.delete %}
|
{% if roles.stock.delete %}
|
||||||
<li><a href='#' id='location-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete location" %}</a></li>
|
<li><a href='#' id='location-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete location" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='col-sm-6'>
|
<div class='col-sm-6'>
|
||||||
|
@ -117,7 +117,7 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
|
|
||||||
response = self.get_stock()
|
response = self.get_stock()
|
||||||
|
|
||||||
self.assertEqual(len(response), 19)
|
self.assertEqual(len(response), 20)
|
||||||
|
|
||||||
def test_filter_by_part(self):
|
def test_filter_by_part(self):
|
||||||
"""
|
"""
|
||||||
@ -126,7 +126,7 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
|
|
||||||
response = self.get_stock(part=25)
|
response = self.get_stock(part=25)
|
||||||
|
|
||||||
self.assertEqual(len(response), 7)
|
self.assertEqual(len(response), 8)
|
||||||
|
|
||||||
response = self.get_stock(part=10004)
|
response = self.get_stock(part=10004)
|
||||||
|
|
||||||
@ -166,7 +166,7 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
self.assertEqual(len(response), 1)
|
self.assertEqual(len(response), 1)
|
||||||
|
|
||||||
response = self.get_stock(depleted=0)
|
response = self.get_stock(depleted=0)
|
||||||
self.assertEqual(len(response), 18)
|
self.assertEqual(len(response), 19)
|
||||||
|
|
||||||
def test_filter_by_in_stock(self):
|
def test_filter_by_in_stock(self):
|
||||||
"""
|
"""
|
||||||
@ -174,7 +174,7 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.get_stock(in_stock=1)
|
response = self.get_stock(in_stock=1)
|
||||||
self.assertEqual(len(response), 16)
|
self.assertEqual(len(response), 17)
|
||||||
|
|
||||||
response = self.get_stock(in_stock=0)
|
response = self.get_stock(in_stock=0)
|
||||||
self.assertEqual(len(response), 3)
|
self.assertEqual(len(response), 3)
|
||||||
@ -185,7 +185,7 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
codes = {
|
codes = {
|
||||||
StockStatus.OK: 17,
|
StockStatus.OK: 18,
|
||||||
StockStatus.DESTROYED: 1,
|
StockStatus.DESTROYED: 1,
|
||||||
StockStatus.LOST: 1,
|
StockStatus.LOST: 1,
|
||||||
StockStatus.DAMAGED: 0,
|
StockStatus.DAMAGED: 0,
|
||||||
@ -218,7 +218,7 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
self.assertIsNotNone(item['serial'])
|
self.assertIsNotNone(item['serial'])
|
||||||
|
|
||||||
response = self.get_stock(serialized=0)
|
response = self.get_stock(serialized=0)
|
||||||
self.assertEqual(len(response), 7)
|
self.assertEqual(len(response), 8)
|
||||||
|
|
||||||
for item in response:
|
for item in response:
|
||||||
self.assertIsNone(item['serial'])
|
self.assertIsNone(item['serial'])
|
||||||
@ -230,7 +230,7 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
|
|
||||||
# First, we can assume that the 'stock expiry' feature is disabled
|
# First, we can assume that the 'stock expiry' feature is disabled
|
||||||
response = self.get_stock(expired=1)
|
response = self.get_stock(expired=1)
|
||||||
self.assertEqual(len(response), 19)
|
self.assertEqual(len(response), 20)
|
||||||
|
|
||||||
# Now, ensure that the expiry date feature is enabled!
|
# Now, ensure that the expiry date feature is enabled!
|
||||||
InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
|
InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
|
||||||
@ -242,7 +242,7 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
self.assertTrue(item['expired'])
|
self.assertTrue(item['expired'])
|
||||||
|
|
||||||
response = self.get_stock(expired=0)
|
response = self.get_stock(expired=0)
|
||||||
self.assertEqual(len(response), 18)
|
self.assertEqual(len(response), 19)
|
||||||
|
|
||||||
for item in response:
|
for item in response:
|
||||||
self.assertFalse(item['expired'])
|
self.assertFalse(item['expired'])
|
||||||
@ -259,7 +259,7 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
self.assertEqual(len(response), 4)
|
self.assertEqual(len(response), 4)
|
||||||
|
|
||||||
response = self.get_stock(expired=0)
|
response = self.get_stock(expired=0)
|
||||||
self.assertEqual(len(response), 15)
|
self.assertEqual(len(response), 16)
|
||||||
|
|
||||||
|
|
||||||
class StockItemTest(StockAPITestCase):
|
class StockItemTest(StockAPITestCase):
|
||||||
|
@ -10,6 +10,8 @@ from common.models import InvenTreeSetting
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from InvenTree.status_codes import StockStatus
|
||||||
|
|
||||||
|
|
||||||
class StockViewTestCase(TestCase):
|
class StockViewTestCase(TestCase):
|
||||||
|
|
||||||
@ -230,3 +232,184 @@ class StockItemTest(StockViewTestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = json.loads(response.content)
|
data = json.loads(response.content)
|
||||||
self.assertFalse(data['form_valid'])
|
self.assertFalse(data['form_valid'])
|
||||||
|
|
||||||
|
|
||||||
|
class StockOwnershipTest(StockViewTestCase):
|
||||||
|
""" Tests for stock ownership views """
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
""" Add another user for ownership tests """
|
||||||
|
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
# Promote existing user with staff, admin and superuser statuses
|
||||||
|
self.user.is_staff = True
|
||||||
|
self.user.is_admin = True
|
||||||
|
self.user.is_superuser = True
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
# Create a new user
|
||||||
|
user = get_user_model()
|
||||||
|
|
||||||
|
self.new_user = user.objects.create_user(
|
||||||
|
username='john',
|
||||||
|
email='john@email.com',
|
||||||
|
password='custom123',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Put the user into a new group with the correct permissions
|
||||||
|
group = Group.objects.create(name='new_group')
|
||||||
|
self.new_user.groups.add(group)
|
||||||
|
|
||||||
|
# Give the group *all* the permissions!
|
||||||
|
for rule in group.rule_sets.all():
|
||||||
|
rule.can_view = True
|
||||||
|
rule.can_change = True
|
||||||
|
rule.can_add = True
|
||||||
|
rule.can_delete = True
|
||||||
|
|
||||||
|
rule.save()
|
||||||
|
|
||||||
|
def enable_ownership(self):
|
||||||
|
# Enable stock location ownership
|
||||||
|
|
||||||
|
InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user)
|
||||||
|
self.assertEqual(True, InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL'))
|
||||||
|
|
||||||
|
def test_owner_control(self):
|
||||||
|
# Test stock location and item ownership
|
||||||
|
from .models import StockLocation, StockItem
|
||||||
|
from users.models import Owner
|
||||||
|
|
||||||
|
user_group = self.user.groups.all()[0]
|
||||||
|
user_group_owner = Owner.get_owner(user_group)
|
||||||
|
new_user_group = self.new_user.groups.all()[0]
|
||||||
|
new_user_group_owner = Owner.get_owner(new_user_group)
|
||||||
|
|
||||||
|
user_as_owner = Owner.get_owner(self.user)
|
||||||
|
new_user_as_owner = Owner.get_owner(self.new_user)
|
||||||
|
|
||||||
|
test_location_id = 4
|
||||||
|
test_item_id = 11
|
||||||
|
|
||||||
|
# Enable ownership control
|
||||||
|
self.enable_ownership()
|
||||||
|
|
||||||
|
# Set ownership on existing location
|
||||||
|
response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)),
|
||||||
|
{'name': 'Office', 'owner': user_group_owner.pk},
|
||||||
|
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
self.assertContains(response, '"form_valid": true', status_code=200)
|
||||||
|
|
||||||
|
# Set ownership on existing item (and change location)
|
||||||
|
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
|
||||||
|
{'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk},
|
||||||
|
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
self.assertContains(response, '"form_valid": true', status_code=200)
|
||||||
|
|
||||||
|
# Logout
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
|
# Login with new user
|
||||||
|
self.client.login(username='john', password='custom123')
|
||||||
|
|
||||||
|
# Test location edit
|
||||||
|
response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)),
|
||||||
|
{'name': 'Office', 'owner': new_user_group_owner.pk},
|
||||||
|
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
|
||||||
|
# Make sure the location's owner is unchanged
|
||||||
|
location = StockLocation.objects.get(pk=test_location_id)
|
||||||
|
self.assertEqual(location.owner, user_group_owner)
|
||||||
|
|
||||||
|
# Test item edit
|
||||||
|
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
|
||||||
|
{'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk},
|
||||||
|
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
|
||||||
|
# Make sure the item's owner is unchanged
|
||||||
|
item = StockItem.objects.get(pk=test_item_id)
|
||||||
|
self.assertEqual(item.owner, user_as_owner)
|
||||||
|
|
||||||
|
# Create new parent location
|
||||||
|
parent_location = {
|
||||||
|
'name': 'John Desk',
|
||||||
|
'description': 'John\'s desk',
|
||||||
|
'owner': new_user_group_owner.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create new parent location
|
||||||
|
response = self.client.post(reverse('stock-location-create'),
|
||||||
|
parent_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
self.assertContains(response, '"form_valid": true', status_code=200)
|
||||||
|
|
||||||
|
# Retrieve created location
|
||||||
|
parent_location = StockLocation.objects.get(name=parent_location['name'])
|
||||||
|
|
||||||
|
# Create new child location
|
||||||
|
new_location = {
|
||||||
|
'name': 'Upper Left Drawer',
|
||||||
|
'description': 'John\'s desk - Upper left drawer',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to create new location with neither parent or owner
|
||||||
|
response = self.client.post(reverse('stock-location-create'),
|
||||||
|
new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
self.assertContains(response, '"form_valid": false', status_code=200)
|
||||||
|
|
||||||
|
# Try to create new location with invalid owner
|
||||||
|
new_location['parent'] = parent_location.id
|
||||||
|
new_location['owner'] = user_group_owner.pk
|
||||||
|
response = self.client.post(reverse('stock-location-create'),
|
||||||
|
new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
self.assertContains(response, '"form_valid": false', status_code=200)
|
||||||
|
|
||||||
|
# Try to create new location with valid owner
|
||||||
|
new_location['owner'] = new_user_group_owner.pk
|
||||||
|
response = self.client.post(reverse('stock-location-create'),
|
||||||
|
new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
self.assertContains(response, '"form_valid": true', status_code=200)
|
||||||
|
|
||||||
|
# Retrieve created location
|
||||||
|
location_created = StockLocation.objects.get(name=new_location['name'])
|
||||||
|
|
||||||
|
# Create new item
|
||||||
|
new_item = {
|
||||||
|
'part': 25,
|
||||||
|
'location': location_created.pk,
|
||||||
|
'quantity': 123,
|
||||||
|
'status': StockStatus.OK,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to create new item with no owner
|
||||||
|
response = self.client.post(reverse('stock-item-create'),
|
||||||
|
new_item, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
self.assertContains(response, '"form_valid": false', status_code=200)
|
||||||
|
|
||||||
|
# Try to create new item with invalid owner
|
||||||
|
new_item['owner'] = user_as_owner.pk
|
||||||
|
response = self.client.post(reverse('stock-item-create'),
|
||||||
|
new_item, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
self.assertContains(response, '"form_valid": false', status_code=200)
|
||||||
|
|
||||||
|
# Try to create new item with valid owner
|
||||||
|
new_item['owner'] = new_user_as_owner.pk
|
||||||
|
response = self.client.post(reverse('stock-item-create'),
|
||||||
|
new_item, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
self.assertContains(response, '"form_valid": true', status_code=200)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
@ -29,8 +29,6 @@ stock_item_detail_urls = [
|
|||||||
|
|
||||||
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
|
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
|
||||||
|
|
||||||
url(r'^test-report-select/', views.StockItemTestReportSelect.as_view(), name='stock-item-test-report-select'),
|
|
||||||
|
|
||||||
url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'),
|
url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'),
|
||||||
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
|
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
|
||||||
url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'),
|
url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'),
|
||||||
@ -62,8 +60,6 @@ stock_urls = [
|
|||||||
|
|
||||||
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'^item/test-report-download/', views.StockItemTestReportDownload.as_view(), name='stock-item-test-report-download'),
|
|
||||||
|
|
||||||
# URLs for StockItem attachments
|
# URLs for StockItem attachments
|
||||||
url(r'^item/attachment/', include([
|
url(r'^item/attachment/', include([
|
||||||
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),
|
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),
|
||||||
|
@ -11,6 +11,8 @@ from django.views.generic import DetailView, ListView, UpdateView
|
|||||||
from django.forms.models import model_to_dict
|
from django.forms.models import model_to_dict
|
||||||
from django.forms import HiddenInput
|
from django.forms import HiddenInput
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
@ -30,10 +32,11 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from report.models import TestReport
|
|
||||||
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
|
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
|
||||||
|
|
||||||
import common.settings
|
import common.settings
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
from users.models import Owner
|
||||||
|
|
||||||
from .admin import StockItemResource
|
from .admin import StockItemResource
|
||||||
|
|
||||||
@ -126,6 +129,7 @@ class StockLocationEdit(AjaxUpdateView):
|
|||||||
""" Customize form data for StockLocation editing.
|
""" Customize form data for StockLocation editing.
|
||||||
|
|
||||||
Limit the choices for 'parent' field to those which make sense.
|
Limit the choices for 'parent' field to those which make sense.
|
||||||
|
If ownership control is enabled and location has parent, disable owner field.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
form = super(AjaxUpdateView, self).get_form()
|
form = super(AjaxUpdateView, self).get_form()
|
||||||
@ -138,8 +142,105 @@ class StockLocationEdit(AjaxUpdateView):
|
|||||||
|
|
||||||
form.fields['parent'].queryset = parent_choices
|
form.fields['parent'].queryset = parent_choices
|
||||||
|
|
||||||
|
# Is ownership control enabled?
|
||||||
|
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||||
|
|
||||||
|
if not stock_ownership_control:
|
||||||
|
# Hide owner field
|
||||||
|
form.fields['owner'].widget = HiddenInput()
|
||||||
|
else:
|
||||||
|
# Get location's owner
|
||||||
|
location_owner = location.owner
|
||||||
|
|
||||||
|
if location_owner:
|
||||||
|
if location.parent:
|
||||||
|
try:
|
||||||
|
# If location has parent and owner: automatically select parent's owner
|
||||||
|
parent_owner = location.parent.owner
|
||||||
|
form.fields['owner'].initial = parent_owner
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# If current owner exists: automatically select it
|
||||||
|
form.fields['owner'].initial = location_owner
|
||||||
|
|
||||||
|
# Update queryset or disable field (only if not admin)
|
||||||
|
if not self.request.user.is_superuser:
|
||||||
|
if type(location_owner.owner) is Group:
|
||||||
|
user_as_owner = Owner.get_owner(self.request.user)
|
||||||
|
queryset = location_owner.get_related_owners(include_group=True)
|
||||||
|
|
||||||
|
if user_as_owner not in queryset:
|
||||||
|
# Only owners or admin can change current owner
|
||||||
|
form.fields['owner'].disabled = True
|
||||||
|
else:
|
||||||
|
form.fields['owner'].queryset = queryset
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
|
||||||
|
def save(self, object, form, **kwargs):
|
||||||
|
""" If location has children and ownership control is enabled:
|
||||||
|
- update owner of all children location of this location
|
||||||
|
- update owner for all stock items at this location
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.object = form.save()
|
||||||
|
|
||||||
|
# Is ownership control enabled?
|
||||||
|
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||||
|
|
||||||
|
if stock_ownership_control:
|
||||||
|
# Get authorized users
|
||||||
|
authorized_owners = self.object.owner.get_related_owners()
|
||||||
|
|
||||||
|
# Update children locations
|
||||||
|
children_locations = self.object.get_children()
|
||||||
|
for child in children_locations:
|
||||||
|
# Check if current owner is subset of new owner
|
||||||
|
if child.owner and authorized_owners:
|
||||||
|
if child.owner in authorized_owners:
|
||||||
|
continue
|
||||||
|
|
||||||
|
child.owner = self.object.owner
|
||||||
|
child.save()
|
||||||
|
|
||||||
|
# Update stock items
|
||||||
|
stock_items = self.object.get_stock_items()
|
||||||
|
|
||||||
|
for stock_item in stock_items:
|
||||||
|
# Check if current owner is subset of new owner
|
||||||
|
if stock_item.owner and authorized_owners:
|
||||||
|
if stock_item.owner in authorized_owners:
|
||||||
|
continue
|
||||||
|
|
||||||
|
stock_item.owner = self.object.owner
|
||||||
|
stock_item.save()
|
||||||
|
|
||||||
|
return self.object
|
||||||
|
|
||||||
|
def validate(self, item, form):
|
||||||
|
""" Check that owner is set if stock ownership control is enabled """
|
||||||
|
|
||||||
|
parent = form.cleaned_data.get('parent', None)
|
||||||
|
|
||||||
|
owner = form.cleaned_data.get('owner', None)
|
||||||
|
|
||||||
|
# Is ownership control enabled?
|
||||||
|
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||||
|
|
||||||
|
if stock_ownership_control:
|
||||||
|
if not owner and not self.request.user.is_superuser:
|
||||||
|
form.add_error('owner', _('Owner is required (ownership control is enabled)'))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if parent.owner:
|
||||||
|
if parent.owner != owner:
|
||||||
|
error = f'Owner requires to be equivalent to parent\'s owner ({parent.owner})'
|
||||||
|
form.add_error('owner', error)
|
||||||
|
except AttributeError:
|
||||||
|
# No parent
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class StockLocationQRCode(QRCodeView):
|
class StockLocationQRCode(QRCodeView):
|
||||||
""" View for displaying a QR code for a StockLocation object """
|
""" View for displaying a QR code for a StockLocation object """
|
||||||
@ -410,92 +511,6 @@ class StockItemTestResultDelete(AjaxDeleteView):
|
|||||||
role_required = 'stock.delete'
|
role_required = 'stock.delete'
|
||||||
|
|
||||||
|
|
||||||
class StockItemTestReportSelect(AjaxView):
|
|
||||||
"""
|
|
||||||
View for selecting a TestReport template,
|
|
||||||
and generating a TestReport as a PDF.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = StockItem
|
|
||||||
ajax_form_title = _("Select Test Report Template")
|
|
||||||
role_required = 'stock.view'
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
|
|
||||||
stock_item = StockItem.objects.get(pk=self.kwargs['pk'])
|
|
||||||
form = StockForms.TestReportFormatForm(stock_item)
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
|
|
||||||
initials = super().get_initial()
|
|
||||||
|
|
||||||
form = self.get_form()
|
|
||||||
options = form.fields['template'].queryset
|
|
||||||
|
|
||||||
# If only a single template is available, pre-select it
|
|
||||||
if options.count() == 1:
|
|
||||||
initials['template'] = options[0]
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
template_id = request.POST.get('template', None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
template = TestReport.objects.get(pk=template_id)
|
|
||||||
except (ValueError, TestReport.DoesNoteExist):
|
|
||||||
raise ValidationError({'template': _("Select valid template")})
|
|
||||||
|
|
||||||
stock_item = StockItem.objects.get(pk=self.kwargs['pk'])
|
|
||||||
|
|
||||||
url = reverse('stock-item-test-report-download')
|
|
||||||
|
|
||||||
url += '?stock_item={id}'.format(id=stock_item.pk)
|
|
||||||
url += '&template={id}'.format(id=template.pk)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'form_valid': True,
|
|
||||||
'url': url,
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.renderJsonResponse(request, self.get_form(), data=data)
|
|
||||||
|
|
||||||
|
|
||||||
class StockItemTestReportDownload(AjaxView):
|
|
||||||
"""
|
|
||||||
Download a TestReport against a StockItem.
|
|
||||||
|
|
||||||
Requires the following arguments to be passed as URL params:
|
|
||||||
|
|
||||||
stock_item - Valid PK of a StockItem object
|
|
||||||
template - Valid PK of a TestReport template object
|
|
||||||
|
|
||||||
"""
|
|
||||||
role_required = 'stock.view'
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
template = request.GET.get('template', None)
|
|
||||||
stock_item = request.GET.get('stock_item', None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
template = TestReport.objects.get(pk=template)
|
|
||||||
except (ValueError, TestReport.DoesNotExist):
|
|
||||||
raise ValidationError({'template': 'Invalid template ID'})
|
|
||||||
|
|
||||||
try:
|
|
||||||
stock_item = StockItem.objects.get(pk=stock_item)
|
|
||||||
except (ValueError, StockItem.DoesNotExist):
|
|
||||||
raise ValidationError({'stock_item': 'Invalid StockItem ID'})
|
|
||||||
|
|
||||||
template.stock_item = stock_item
|
|
||||||
|
|
||||||
return template.render(request)
|
|
||||||
|
|
||||||
|
|
||||||
class StockExportOptions(AjaxView):
|
class StockExportOptions(AjaxView):
|
||||||
""" Form for selecting StockExport options """
|
""" Form for selecting StockExport options """
|
||||||
|
|
||||||
@ -1169,6 +1184,18 @@ class StockAdjust(AjaxView, FormMixin):
|
|||||||
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
|
# Is ownership control enabled?
|
||||||
|
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||||
|
|
||||||
|
if stock_ownership_control:
|
||||||
|
# Fetch destination owner
|
||||||
|
destination_owner = destination.owner
|
||||||
|
|
||||||
|
if destination_owner:
|
||||||
|
# Update owner
|
||||||
|
item.owner = destination_owner
|
||||||
|
item.save()
|
||||||
|
|
||||||
if count == 0:
|
if count == 0:
|
||||||
return _('No items were moved')
|
return _('No items were moved')
|
||||||
|
|
||||||
@ -1235,8 +1262,76 @@ class StockItemEdit(AjaxUpdateView):
|
|||||||
if not item.part.trackable and not item.serialized:
|
if not item.part.trackable and not item.serialized:
|
||||||
form.fields['serial'].widget = HiddenInput()
|
form.fields['serial'].widget = HiddenInput()
|
||||||
|
|
||||||
|
location = item.location
|
||||||
|
|
||||||
|
# Is ownership control enabled?
|
||||||
|
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||||
|
|
||||||
|
if not stock_ownership_control:
|
||||||
|
form.fields['owner'].widget = HiddenInput()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
location_owner = location.owner
|
||||||
|
except AttributeError:
|
||||||
|
location_owner = None
|
||||||
|
|
||||||
|
# Check if location has owner
|
||||||
|
if location_owner:
|
||||||
|
form.fields['owner'].initial = location_owner
|
||||||
|
|
||||||
|
# Check location's owner type and filter potential owners
|
||||||
|
if type(location_owner.owner) is Group:
|
||||||
|
user_as_owner = Owner.get_owner(self.request.user)
|
||||||
|
queryset = location_owner.get_related_owners(include_group=True)
|
||||||
|
|
||||||
|
if user_as_owner in queryset:
|
||||||
|
form.fields['owner'].initial = user_as_owner
|
||||||
|
|
||||||
|
form.fields['owner'].queryset = queryset
|
||||||
|
|
||||||
|
elif type(location_owner.owner) is get_user_model():
|
||||||
|
# If location's owner is a user: automatically set owner field and disable it
|
||||||
|
form.fields['owner'].disabled = True
|
||||||
|
form.fields['owner'].initial = location_owner
|
||||||
|
|
||||||
|
try:
|
||||||
|
item_owner = item.owner
|
||||||
|
except AttributeError:
|
||||||
|
item_owner = None
|
||||||
|
|
||||||
|
# Check if item has owner
|
||||||
|
if item_owner:
|
||||||
|
form.fields['owner'].initial = item_owner
|
||||||
|
|
||||||
|
# Check item's owner type and filter potential owners
|
||||||
|
if type(item_owner.owner) is Group:
|
||||||
|
user_as_owner = Owner.get_owner(self.request.user)
|
||||||
|
queryset = item_owner.get_related_owners(include_group=True)
|
||||||
|
|
||||||
|
if user_as_owner in queryset:
|
||||||
|
form.fields['owner'].initial = user_as_owner
|
||||||
|
|
||||||
|
form.fields['owner'].queryset = queryset
|
||||||
|
|
||||||
|
elif type(item_owner.owner) is get_user_model():
|
||||||
|
# If item's owner is a user: automatically set owner field and disable it
|
||||||
|
form.fields['owner'].disabled = True
|
||||||
|
form.fields['owner'].initial = item_owner
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
|
||||||
|
def validate(self, item, form):
|
||||||
|
""" Check that owner is set if stock ownership control is enabled """
|
||||||
|
|
||||||
|
owner = form.cleaned_data.get('owner', None)
|
||||||
|
|
||||||
|
# Is ownership control enabled?
|
||||||
|
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||||
|
|
||||||
|
if stock_ownership_control:
|
||||||
|
if not owner and not self.request.user.is_superuser:
|
||||||
|
form.add_error('owner', _('Owner is required (ownership control is enabled)'))
|
||||||
|
|
||||||
|
|
||||||
class StockItemConvert(AjaxUpdateView):
|
class StockItemConvert(AjaxUpdateView):
|
||||||
"""
|
"""
|
||||||
@ -1289,6 +1384,76 @@ class StockLocationCreate(AjaxCreateView):
|
|||||||
|
|
||||||
return initials
|
return initials
|
||||||
|
|
||||||
|
def get_form(self):
|
||||||
|
""" Disable owner field when:
|
||||||
|
- creating child location
|
||||||
|
- and stock ownership control is enable
|
||||||
|
"""
|
||||||
|
|
||||||
|
form = super().get_form()
|
||||||
|
|
||||||
|
# Is ownership control enabled?
|
||||||
|
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||||
|
|
||||||
|
if not stock_ownership_control:
|
||||||
|
# Hide owner field
|
||||||
|
form.fields['owner'].widget = HiddenInput()
|
||||||
|
else:
|
||||||
|
# If user did not selected owner: automatically match to parent's owner
|
||||||
|
if not form['owner'].data:
|
||||||
|
try:
|
||||||
|
parent_id = form['parent'].value()
|
||||||
|
parent = StockLocation.objects.get(pk=parent_id)
|
||||||
|
|
||||||
|
if parent:
|
||||||
|
form.fields['owner'].initial = parent.owner
|
||||||
|
if not self.request.user.is_superuser:
|
||||||
|
form.fields['owner'].disabled = True
|
||||||
|
except StockLocation.DoesNotExist:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return form
|
||||||
|
|
||||||
|
def save(self, form):
|
||||||
|
""" If parent location exists then use it to set the owner """
|
||||||
|
|
||||||
|
self.object = form.save(commit=False)
|
||||||
|
|
||||||
|
parent = form.cleaned_data.get('parent', None)
|
||||||
|
|
||||||
|
if parent:
|
||||||
|
# Select parent's owner
|
||||||
|
self.object.owner = parent.owner
|
||||||
|
|
||||||
|
self.object.save()
|
||||||
|
|
||||||
|
return self.object
|
||||||
|
|
||||||
|
def validate(self, item, form):
|
||||||
|
""" Check that owner is set if stock ownership control is enabled """
|
||||||
|
|
||||||
|
parent = form.cleaned_data.get('parent', None)
|
||||||
|
|
||||||
|
owner = form.cleaned_data.get('owner', None)
|
||||||
|
|
||||||
|
# Is ownership control enabled?
|
||||||
|
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||||
|
|
||||||
|
if stock_ownership_control:
|
||||||
|
if not owner and not self.request.user.is_superuser:
|
||||||
|
form.add_error('owner', _('Owner is required (ownership control is enabled)'))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if parent.owner:
|
||||||
|
if parent.owner != owner:
|
||||||
|
error = f'Owner requires to be equivalent to parent\'s owner ({parent.owner})'
|
||||||
|
form.add_error('owner', error)
|
||||||
|
except AttributeError:
|
||||||
|
# No parent
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class StockItemSerialize(AjaxUpdateView):
|
class StockItemSerialize(AjaxUpdateView):
|
||||||
""" View for manually serializing a StockItem """
|
""" View for manually serializing a StockItem """
|
||||||
@ -1484,6 +1649,41 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
if form['supplier_part'].value() is not None:
|
if form['supplier_part'].value() is not None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
location = None
|
||||||
|
try:
|
||||||
|
loc_id = form['location'].value()
|
||||||
|
location = StockLocation.objects.get(pk=loc_id)
|
||||||
|
except StockLocation.DoesNotExist:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Is ownership control enabled?
|
||||||
|
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||||
|
if not stock_ownership_control:
|
||||||
|
form.fields['owner'].widget = HiddenInput()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
location_owner = location.owner
|
||||||
|
except AttributeError:
|
||||||
|
location_owner = None
|
||||||
|
|
||||||
|
if location_owner:
|
||||||
|
# Check location's owner type and filter potential owners
|
||||||
|
if type(location_owner.owner) is Group:
|
||||||
|
user_as_owner = Owner.get_owner(self.request.user)
|
||||||
|
queryset = location_owner.get_related_owners()
|
||||||
|
|
||||||
|
if user_as_owner in queryset:
|
||||||
|
form.fields['owner'].initial = user_as_owner
|
||||||
|
|
||||||
|
form.fields['owner'].queryset = queryset
|
||||||
|
|
||||||
|
elif type(location_owner.owner) is get_user_model():
|
||||||
|
# If location's owner is a user: automatically set owner field and disable it
|
||||||
|
form.fields['owner'].disabled = True
|
||||||
|
form.fields['owner'].initial = location_owner
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
@ -1560,10 +1760,15 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
|
|
||||||
data = form.cleaned_data
|
data = form.cleaned_data
|
||||||
|
|
||||||
part = data['part']
|
part = data.get('part', None)
|
||||||
|
|
||||||
quantity = data.get('quantity', None)
|
quantity = data.get('quantity', None)
|
||||||
|
|
||||||
|
owner = data.get('owner', None)
|
||||||
|
|
||||||
|
if not part:
|
||||||
|
return
|
||||||
|
|
||||||
if not quantity:
|
if not quantity:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -1582,8 +1787,13 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
sn = str(sn).strip()
|
sn = str(sn).strip()
|
||||||
|
|
||||||
if len(sn) > 0:
|
if len(sn) > 0:
|
||||||
|
try:
|
||||||
serials = extract_serial_numbers(sn, quantity)
|
serials = extract_serial_numbers(sn, quantity)
|
||||||
|
except ValidationError as e:
|
||||||
|
serials = None
|
||||||
|
form.add_error('serial_numbers', e.messages)
|
||||||
|
|
||||||
|
if serials is not None:
|
||||||
existing = part.find_conflicting_serial_numbers(serials)
|
existing = part.find_conflicting_serial_numbers(serials)
|
||||||
|
|
||||||
if len(existing) > 0:
|
if len(existing) > 0:
|
||||||
@ -1594,6 +1804,15 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
_('Serial numbers already exist') + ': ' + exists
|
_('Serial numbers already exist') + ': ' + exists
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Is ownership control enabled?
|
||||||
|
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||||
|
|
||||||
|
if stock_ownership_control:
|
||||||
|
# Check if owner is set
|
||||||
|
if not owner and not self.request.user.is_superuser:
|
||||||
|
form.add_error('owner', _('Owner is required (ownership control is enabled)'))
|
||||||
|
return
|
||||||
|
|
||||||
def save(self, form, **kwargs):
|
def save(self, form, **kwargs):
|
||||||
"""
|
"""
|
||||||
Create a new StockItem based on the provided form data.
|
Create a new StockItem based on the provided form data.
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_BUILD" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_BUILD" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="STOCK_OWNERSHIP_CONTROL" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -1,6 +1,5 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% if roles.stock.change %}
|
|
||||||
<div id='attachment-buttons'>
|
<div id='attachment-buttons'>
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button type='button' class='btn btn-success' id='new-attachment'>
|
<button type='button' class='btn btn-success' id='new-attachment'>
|
||||||
@ -8,7 +7,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class='dropzone' id='attachment-dropzone'>
|
<div class='dropzone' id='attachment-dropzone'>
|
||||||
<table class='table table-striped table-condensed' data-toolbar='#attachment-buttons' id='attachment-table'>
|
<table class='table table-striped table-condensed' data-toolbar='#attachment-buttons' id='attachment-table'>
|
||||||
|
@ -121,6 +121,7 @@ InvenTree
|
|||||||
<script type='text/javascript' src="{% url 'part.js' %}"></script>
|
<script type='text/javascript' src="{% url 'part.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% url 'modals.js' %}"></script>
|
<script type='text/javascript' src="{% url 'modals.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% url 'label.js' %}"></script>
|
<script type='text/javascript' src="{% url 'label.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% url 'report.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% url 'stock.js' %}"></script>
|
<script type='text/javascript' src="{% url 'stock.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% url 'build.js' %}"></script>
|
<script type='text/javascript' src="{% url 'build.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% url 'order.js' %}"></script>
|
<script type='text/javascript' src="{% url 'order.js' %}"></script>
|
||||||
|
@ -214,7 +214,6 @@ function loadBomTable(table, options) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!options.editable) {
|
|
||||||
cols.push(
|
cols.push(
|
||||||
{
|
{
|
||||||
field: 'sub_part_detail.stock',
|
field: 'sub_part_detail.stock',
|
||||||
@ -254,7 +253,6 @@ function loadBomTable(table, options) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
cols.push(
|
cols.push(
|
||||||
{
|
{
|
||||||
|
@ -133,8 +133,17 @@ function selectLabel(labels, items, options={}) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Construct form
|
// Construct form
|
||||||
var html = `
|
var html = '';
|
||||||
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
${items.length} {% trans "stock items selected" %}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
|
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
|
||||||
<div class='form-group'>
|
<div class='form-group'>
|
||||||
<label class='control-label requiredField' for='id_label'>
|
<label class='control-label requiredField' for='id_label'>
|
||||||
|
133
InvenTree/templates/js/report.js
Normal file
133
InvenTree/templates/js/report.js
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
|
||||||
|
function selectTestReport(reports, items, options={}) {
|
||||||
|
/**
|
||||||
|
* Present the user with the available test reports,
|
||||||
|
* and allow them to select which test report to print.
|
||||||
|
*
|
||||||
|
* The intent is that the available report templates have been requested
|
||||||
|
* (via AJAX) from the server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var modal = options.modal || '#modal-form';
|
||||||
|
|
||||||
|
var report_list = makeOptionsList(
|
||||||
|
reports,
|
||||||
|
function(item) {
|
||||||
|
var text = item.name;
|
||||||
|
|
||||||
|
if (item.description) {
|
||||||
|
text += ` - ${item.description}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
function(item) {
|
||||||
|
return item.pk;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Construct form
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
${items.length} {% trans "stock items selected" %}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
|
||||||
|
<div class='form-group'>
|
||||||
|
<label class='control-label requiredField' for='id_report'>
|
||||||
|
{% trans "Select Label" %}
|
||||||
|
</label>
|
||||||
|
<div class='controls'>
|
||||||
|
<select id='id_report' class='select form-control name='report'>
|
||||||
|
${report_list}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
modal: modal,
|
||||||
|
});
|
||||||
|
|
||||||
|
modalEnable(modal, true);
|
||||||
|
modalSetTitle(modal, '{% trans "Select Test Report Template" %}');
|
||||||
|
modalSetContent(modal, html);
|
||||||
|
|
||||||
|
attachSelect(modal);
|
||||||
|
|
||||||
|
modalSubmit(modal, function() {
|
||||||
|
|
||||||
|
var label = $(modal).find('#id_report');
|
||||||
|
|
||||||
|
var pk = label.val();
|
||||||
|
|
||||||
|
closeModal(modal);
|
||||||
|
|
||||||
|
if (options.success) {
|
||||||
|
options.success(pk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function printTestReports(items, options={}) {
|
||||||
|
/**
|
||||||
|
* Print test reports for the provided stock item(s)
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (items.length == 0) {
|
||||||
|
showAlertDialog(
|
||||||
|
'{% trans "Select Stock Items" %}',
|
||||||
|
'{% trans "Stock item(s) must be selected before printing reports" %}'
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request available labels from the server
|
||||||
|
inventreeGet(
|
||||||
|
'{% url "api-stockitem-testreport-list" %}',
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
items: items,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
success: function(response) {
|
||||||
|
if (response.length == 0) {
|
||||||
|
showAlertDialog(
|
||||||
|
'{% trans "No Reports Found" %}',
|
||||||
|
'{% trans "No report templates found which match selected stock item(s)" %}',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select report template to print
|
||||||
|
selectTestReport(
|
||||||
|
response,
|
||||||
|
items,
|
||||||
|
{
|
||||||
|
success: function(pk) {
|
||||||
|
var href = `/api/report/test/${pk}/print/?`;
|
||||||
|
|
||||||
|
items.forEach(function(item) {
|
||||||
|
href += `items[]=${item}&`;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
@ -625,9 +625,19 @@ function loadStockTable(table, options) {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
if (options.buttons) {
|
if (options.buttons) {
|
||||||
linkButtonsToSelection(table, options.buttons);
|
linkButtonsToSelection(table, options.buttons);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
linkButtonsToSelection(
|
||||||
|
table,
|
||||||
|
[
|
||||||
|
'#stock-print-options',
|
||||||
|
'#stock-options',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
function stockAdjustment(action) {
|
function stockAdjustment(action) {
|
||||||
var items = $("#stock-table").bootstrapTable("getSelections");
|
var items = $("#stock-table").bootstrapTable("getSelections");
|
||||||
@ -665,6 +675,7 @@ function loadStockTable(table, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Automatically link button callbacks
|
// Automatically link button callbacks
|
||||||
|
|
||||||
$('#multi-item-print-label').click(function() {
|
$('#multi-item-print-label').click(function() {
|
||||||
var selections = $('#stock-table').bootstrapTable('getSelections');
|
var selections = $('#stock-table').bootstrapTable('getSelections');
|
||||||
|
|
||||||
@ -677,6 +688,18 @@ function loadStockTable(table, options) {
|
|||||||
printStockItemLabels(items);
|
printStockItemLabels(items);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#multi-item-print-test-report').click(function() {
|
||||||
|
var selections = $('#stock-table').bootstrapTable('getSelections');
|
||||||
|
|
||||||
|
var items = [];
|
||||||
|
|
||||||
|
selections.forEach(function(item) {
|
||||||
|
items.push(item.pk);
|
||||||
|
});
|
||||||
|
|
||||||
|
printTestReports(items);
|
||||||
|
})
|
||||||
|
|
||||||
$('#multi-item-stocktake').click(function() {
|
$('#multi-item-stocktake').click(function() {
|
||||||
stockAdjustment('count');
|
stockAdjustment('count');
|
||||||
});
|
});
|
||||||
|
@ -1,23 +1,39 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
|
||||||
|
{% if owner_control.value == "True" %}
|
||||||
|
{% authorized_owners location.owner as owners %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div id='button-toolbar'>
|
<div id='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'>
|
||||||
<button class='btn btn-default' id='stock-export' title='{% trans "Export Stock Information" %}'>
|
<button class='btn btn-default' id='stock-export' title='{% trans "Export Stock Information" %}'>
|
||||||
<span class='fas fa-file-download'></span> {% trans "Export" %}
|
<span class='fas fa-download'></span>
|
||||||
</button>
|
</button>
|
||||||
{% if read_only %}
|
|
||||||
{% else %}
|
{% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %}
|
||||||
{% if roles.stock.add %}
|
{% if roles.stock.add %}
|
||||||
<button class="btn btn-success" id='item-create'>
|
<button class="btn btn-success" id='item-create' title='{% trans "New Stock Item" %}'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
|
<span class='fas fa-plus-circle'></span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class='btn-group'>
|
||||||
|
<button id='stock-print-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown" title='{% trans "Printing Actions" %}'>
|
||||||
|
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||||
|
</button>
|
||||||
|
<ul class='dropdown-menu'>
|
||||||
|
<li><a href='#' id='multi-item-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
|
||||||
|
<li><a href='#' id='multi-item-print-test-report' title='{% trans "Print test reports" %}'><span class='fas fa-file-pdf'></span> {% trans "Print test reports" %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
{% if roles.stock.change or roles.stock.delete %}
|
{% if roles.stock.change or roles.stock.delete %}
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown" title='{% trans "Stock Options" %}'>
|
||||||
|
<span class='fas fa-boxes'></span> <span class="caret"></span>
|
||||||
|
</button>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href='#' id='multi-item-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
|
|
||||||
{% if roles.stock.change %}
|
{% if roles.stock.change %}
|
||||||
<li><a href="#" id='multi-item-add' title='{% trans "Add to selected stock items" %}'><span class='fas fa-plus-circle'></span> {% trans "Add stock" %}</a></li>
|
<li><a href="#" id='multi-item-add' title='{% trans "Add to selected stock items" %}'><span class='fas fa-plus-circle'></span> {% trans "Add stock" %}</a></li>
|
||||||
<li><a href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li>
|
<li><a href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li>
|
||||||
@ -33,12 +49,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
<div class='filter-list' id='filter-list-stock'>
|
<div class='filter-list' id='filter-list-stock'>
|
||||||
<!-- An empty div in which the filter list will be constructed -->
|
<!-- An empty div in which the filter list will be constructed -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='stock-table'>
|
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='stock-table'>
|
||||||
</table>
|
</table>
|
||||||
|
@ -11,7 +11,7 @@ from django.contrib.auth.models import Group
|
|||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from users.models import RuleSet
|
from users.models import RuleSet, Owner
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@ -215,8 +215,17 @@ class InvenTreeUserAdmin(UserAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnerAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Custom admin interface for the Owner model
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
admin.site.unregister(Group)
|
admin.site.unregister(Group)
|
||||||
admin.site.register(Group, RoleGroupAdmin)
|
admin.site.register(Group, RoleGroupAdmin)
|
||||||
|
|
||||||
admin.site.unregister(User)
|
admin.site.unregister(User)
|
||||||
admin.site.register(User, InvenTreeUserAdmin)
|
admin.site.register(User, InvenTreeUserAdmin)
|
||||||
|
|
||||||
|
admin.site.register(Owner, OwnerAdmin)
|
||||||
|
@ -16,6 +16,11 @@ class UsersConfig(AppConfig):
|
|||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.update_owners()
|
||||||
|
except (OperationalError, ProgrammingError):
|
||||||
|
pass
|
||||||
|
|
||||||
def assign_permissions(self):
|
def assign_permissions(self):
|
||||||
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
@ -31,3 +36,17 @@ class UsersConfig(AppConfig):
|
|||||||
for group in Group.objects.all():
|
for group in Group.objects.all():
|
||||||
|
|
||||||
update_group_roles(group)
|
update_group_roles(group)
|
||||||
|
|
||||||
|
def update_owners(self):
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from users.models import Owner
|
||||||
|
|
||||||
|
# Create group owners
|
||||||
|
for group in Group.objects.all():
|
||||||
|
Owner.create(group)
|
||||||
|
|
||||||
|
# Create user owners
|
||||||
|
for user in get_user_model().objects.all():
|
||||||
|
Owner.create(user)
|
||||||
|
27
InvenTree/users/migrations/0005_owner_model.py
Normal file
27
InvenTree/users/migrations/0005_owner_model.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2021-01-11 18:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('users', '0004_auto_20210113_1909'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Owner',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('owner_id', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('owner_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='owner',
|
||||||
|
constraint=models.UniqueConstraint(fields=('owner_type', 'owner_id'), name='unique_owner'),
|
||||||
|
),
|
||||||
|
]
|
@ -1,12 +1,16 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group, Permission
|
from django.contrib.auth.models import Group, Permission
|
||||||
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.db.models import UniqueConstraint, Q
|
||||||
|
from django.db.utils import IntegrityError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save, post_delete
|
||||||
|
|
||||||
|
|
||||||
class RuleSet(models.Model):
|
class RuleSet(models.Model):
|
||||||
@ -116,6 +120,7 @@ class RuleSet(models.Model):
|
|||||||
'report_reportasset',
|
'report_reportasset',
|
||||||
'report_testreport',
|
'report_testreport',
|
||||||
'part_partstar',
|
'part_partstar',
|
||||||
|
'users_owner',
|
||||||
|
|
||||||
# Third-party tables
|
# Third-party tables
|
||||||
'error_report_error',
|
'error_report_error',
|
||||||
@ -350,7 +355,7 @@ def update_group_roles(group, debug=False):
|
|||||||
print(f"Removing permission {perm} from group {group.name}")
|
print(f"Removing permission {perm} from group {group.name}")
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Group)
|
@receiver(post_save, sender=Group, dispatch_uid='create_missing_rule_sets')
|
||||||
def create_missing_rule_sets(sender, instance, **kwargs):
|
def create_missing_rule_sets(sender, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
Called *after* a Group object is saved.
|
Called *after* a Group object is saved.
|
||||||
@ -392,3 +397,151 @@ def check_user_role(user, role, permission):
|
|||||||
|
|
||||||
# No matching permissions found
|
# No matching permissions found
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Owner(models.Model):
|
||||||
|
"""
|
||||||
|
The Owner class is a proxy for a Group or User instance.
|
||||||
|
Owner can be associated to any InvenTree model (part, stock, build, etc.)
|
||||||
|
|
||||||
|
owner_type: Model type (Group or User)
|
||||||
|
owner_id: Group or User instance primary key
|
||||||
|
owner: Returns the Group or User instance combining the owner_type and owner_id fields
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
# Ensure all owners are unique
|
||||||
|
constraints = [
|
||||||
|
UniqueConstraint(fields=['owner_type', 'owner_id'],
|
||||||
|
name='unique_owner')
|
||||||
|
]
|
||||||
|
|
||||||
|
owner_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
|
||||||
|
owner_id = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
owner = GenericForeignKey('owner_type', 'owner_id')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
""" Defines the owner string representation """
|
||||||
|
return f'{self.owner} ({self.owner_type.name})'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, obj):
|
||||||
|
""" Check if owner exist then create new owner entry """
|
||||||
|
|
||||||
|
# Check for existing owner
|
||||||
|
existing_owner = cls.get_owner(obj)
|
||||||
|
|
||||||
|
if not existing_owner:
|
||||||
|
# Create new owner
|
||||||
|
try:
|
||||||
|
return cls.objects.create(owner=obj)
|
||||||
|
except IntegrityError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return existing_owner
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_owner(cls, user_or_group):
|
||||||
|
""" Get owner instance for a group or user """
|
||||||
|
|
||||||
|
user_model = get_user_model()
|
||||||
|
owner = None
|
||||||
|
content_type_id = 0
|
||||||
|
content_type_id_list = [ContentType.objects.get_for_model(Group).id,
|
||||||
|
ContentType.objects.get_for_model(user_model).id]
|
||||||
|
|
||||||
|
# If instance type is obvious: set content type
|
||||||
|
if type(user_or_group) is Group:
|
||||||
|
content_type_id = content_type_id_list[0]
|
||||||
|
elif type(user_or_group) is get_user_model():
|
||||||
|
content_type_id = content_type_id_list[1]
|
||||||
|
|
||||||
|
if content_type_id:
|
||||||
|
try:
|
||||||
|
owner = Owner.objects.get(owner_id=user_or_group.id,
|
||||||
|
owner_type=content_type_id)
|
||||||
|
except Owner.DoesNotExist:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Check whether user_or_group is a Group instance
|
||||||
|
try:
|
||||||
|
group = Group.objects.get(pk=user_or_group.id)
|
||||||
|
except Group.DoesNotExist:
|
||||||
|
group = None
|
||||||
|
|
||||||
|
if group:
|
||||||
|
try:
|
||||||
|
owner = Owner.objects.get(owner_id=user_or_group.id,
|
||||||
|
owner_type=content_type_id_list[0])
|
||||||
|
except Owner.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return owner
|
||||||
|
|
||||||
|
# Check whether user_or_group is a User instance
|
||||||
|
try:
|
||||||
|
user = user_model.objects.get(pk=user_or_group.id)
|
||||||
|
except user_model.DoesNotExist:
|
||||||
|
user = None
|
||||||
|
|
||||||
|
if user:
|
||||||
|
try:
|
||||||
|
owner = Owner.objects.get(owner_id=user_or_group.id,
|
||||||
|
owner_type=content_type_id_list[1])
|
||||||
|
except Owner.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return owner
|
||||||
|
|
||||||
|
return owner
|
||||||
|
|
||||||
|
def get_related_owners(self, include_group=False):
|
||||||
|
"""
|
||||||
|
Get all owners "related" to an owner.
|
||||||
|
This method is useful to retrieve all "user-type" owners linked to a "group-type" owner
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_model = get_user_model()
|
||||||
|
related_owners = None
|
||||||
|
|
||||||
|
if type(self.owner) is Group:
|
||||||
|
users = user_model.objects.filter(groups__name=self.owner.name)
|
||||||
|
|
||||||
|
if include_group:
|
||||||
|
# Include "group-type" owner in the query
|
||||||
|
query = Q(owner_id__in=users, owner_type=ContentType.objects.get_for_model(user_model).id) | \
|
||||||
|
Q(owner_id=self.owner.id, owner_type=ContentType.objects.get_for_model(Group).id)
|
||||||
|
else:
|
||||||
|
query = Q(owner_id__in=users, owner_type=ContentType.objects.get_for_model(user_model).id)
|
||||||
|
|
||||||
|
related_owners = Owner.objects.filter(query)
|
||||||
|
|
||||||
|
elif type(self.owner) is user_model:
|
||||||
|
related_owners = [self]
|
||||||
|
|
||||||
|
return related_owners
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Group, dispatch_uid='create_owner')
|
||||||
|
@receiver(post_save, sender=get_user_model(), dispatch_uid='create_owner')
|
||||||
|
def create_owner(sender, instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Callback function to create a new owner instance
|
||||||
|
after either a new group or user instance is saved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
Owner.create(obj=instance)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=Group, dispatch_uid='delete_owner')
|
||||||
|
@receiver(post_delete, sender=get_user_model(), dispatch_uid='delete_owner')
|
||||||
|
def delete_owner(sender, instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Callback function to delete an owner instance
|
||||||
|
after either a new group or user instance is deleted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
owner = Owner.get_owner(instance)
|
||||||
|
owner.delete()
|
||||||
|
@ -3,9 +3,10 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
from users.models import RuleSet
|
from users.models import RuleSet, Owner
|
||||||
|
|
||||||
|
|
||||||
class RuleSetModelTest(TestCase):
|
class RuleSetModelTest(TestCase):
|
||||||
@ -157,3 +158,48 @@ class RuleSetModelTest(TestCase):
|
|||||||
|
|
||||||
# There should now not be any permissions assigned to this group
|
# There should now not be any permissions assigned to this group
|
||||||
self.assertEqual(group.permissions.count(), 0)
|
self.assertEqual(group.permissions.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnerModelTest(TestCase):
|
||||||
|
"""
|
||||||
|
Some simplistic tests to ensure the Owner model is setup correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
""" Add users and groups """
|
||||||
|
|
||||||
|
# Create a new user
|
||||||
|
self.user = get_user_model().objects.create_user(
|
||||||
|
username='john',
|
||||||
|
email='john@email.com',
|
||||||
|
password='custom123',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Put the user into a new group
|
||||||
|
self.group = Group.objects.create(name='new_group')
|
||||||
|
self.user.groups.add(self.group)
|
||||||
|
|
||||||
|
def test_owner(self):
|
||||||
|
|
||||||
|
# Check that owner was created for user
|
||||||
|
user_as_owner = Owner.get_owner(self.user)
|
||||||
|
self.assertEqual(type(user_as_owner), Owner)
|
||||||
|
|
||||||
|
# Check that owner was created for group
|
||||||
|
group_as_owner = Owner.get_owner(self.group)
|
||||||
|
self.assertEqual(type(group_as_owner), Owner)
|
||||||
|
|
||||||
|
# Get related owners (user + group)
|
||||||
|
related_owners = group_as_owner.get_related_owners(include_group=True)
|
||||||
|
self.assertTrue(user_as_owner in related_owners)
|
||||||
|
self.assertTrue(group_as_owner in related_owners)
|
||||||
|
|
||||||
|
# Delete user and verify owner was deleted too
|
||||||
|
self.user.delete()
|
||||||
|
user_as_owner = Owner.get_owner(self.user)
|
||||||
|
self.assertEqual(user_as_owner, None)
|
||||||
|
|
||||||
|
# Delete group and verify owner was deleted too
|
||||||
|
self.group.delete()
|
||||||
|
group_as_owner = Owner.get_owner(self.group)
|
||||||
|
self.assertEqual(group_as_owner, None)
|
||||||
|
4
tasks.py
4
tasks.py
@ -186,6 +186,10 @@ def translate(c):
|
|||||||
manage(c, "makemessages -e py -e html -e js")
|
manage(c, "makemessages -e py -e html -e js")
|
||||||
manage(c, "compilemessages")
|
manage(c, "compilemessages")
|
||||||
|
|
||||||
|
path = os.path.join('InvenTree', 'script', 'translation_stats.py')
|
||||||
|
|
||||||
|
c.run(f'python {path}')
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def style(c):
|
def style(c):
|
||||||
"""
|
"""
|
||||||
|
Loading…
Reference in New Issue
Block a user