Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2021-01-19 09:31:07 +11:00
commit a6030d4cc8
51 changed files with 3163 additions and 1611 deletions

View File

@ -22,7 +22,28 @@ from django.utils.translation import gettext_lazy as _
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, ...)
@ -39,10 +60,17 @@ with open(cfg_filename, 'r') as cfg:
# Default action is to run the system in Debug mode
# 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
log_level = CONFIG.get('log_level', 'DEBUG').upper()
log_level = get_setting(
'INVENTREE_LOG_LEVEL',
CONFIG.get('log_level', 'DEBUG')
)
logging.basicConfig(
level=log_level,
format="%(asctime)s %(levelname)s %(message)s",
@ -75,6 +103,7 @@ if os.getenv("INVENTREE_SECRET_KEY"):
else:
# Secret key passed in by file location
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
if key_file:
if os.path.isfile(key_file):
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY_FILE")
@ -112,7 +141,12 @@ if cors_opt:
STATIC_URL = '/static/'
# 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 = [
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/'
# 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:
logger.info("InvenTree running in DEBUG mode")
@ -133,30 +172,6 @@ if DEBUG:
logger.info(f"MEDIA_ROOT: '{MEDIA_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
INSTALLED_APPS = [
@ -430,16 +445,17 @@ if not type(EXTRA_URL_SCHEMES) in [list]:
EXTRA_URL_SCHEMES = []
# 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')
# If a new language translation is supported, it must be added here
LANGUAGES = [
('en', _('English')),
('de', _('German')),
('fr', _('French')),
('de', _('German')),
('pk', _('Polish')),
('tr', _('Turkish')),
]
# Currencies available for use
@ -491,10 +507,15 @@ CRISPY_TEMPLATE_PACK = 'bootstrap3'
# Use database transactions when importing / exporting data
IMPORT_EXPORT_USE_TRANSACTIONS = True
BACKUP_DIR = get_setting(
'INVENTREE_BACKUP_DIR',
CONFIG.get('backup_dir', tempfile.gettempdir()),
)
# Settings for dbbsettings app
DBBACKUP_STORAGE = 'django.core.files.storage.FileSystemStorage'
DBBACKUP_STORAGE_OPTIONS = {
'location': CONFIG.get('backup_dir', tempfile.gettempdir()),
'location': BACKUP_DIR,
}
# Internal IP addresses allowed to see the debug toolbar

View File

@ -29,6 +29,7 @@ from stock.api import stock_api_urls
from build.api import build_api_urls
from order.api import order_api_urls
from label.api import label_api_urls
from report.api import report_api_urls
from django.conf import settings
from django.conf.urls.static import static
@ -60,6 +61,7 @@ apipatterns = [
url(r'^build/', include(build_api_urls)),
url(r'^order/', include(order_api_urls)),
url(r'^label/', include(label_api_urls)),
url(r'^report/', include(report_api_urls)),
# 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'^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'^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'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.js'), name='table_filters.js'),
]

View File

@ -196,6 +196,13 @@ class InvenTreeSetting(models.Model):
'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': {
'name': _('Build Order Reference Prefix'),
'description': _('Prefix value for build order reference'),

View File

@ -107,13 +107,6 @@ static_root: '../inventree_static'
# If unspecified, the local user's temp directory will be used
#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
# InvenTree uses the django-tex plugin to enable LaTeX report rendering
# Ref: https://pypi.org/project/django-tex/

View File

@ -310,7 +310,7 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin)
"""
queryset = StockLocationLabel.objects.all()
seiralizers_class = StockLocationLabelSerializer
seiralizer_class = StockLocationLabelSerializer
def get(self, request, *args, **kwargs):

View 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],
}
)

View File

@ -1,4 +1,4 @@
# Tests for Part Parameters
# Tests for labels
# -*- coding: utf-8 -*-
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

View File

@ -1856,9 +1856,6 @@ class BomItem(models.Model):
self.clean()
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('bom-item-detail', kwargs={'pk': self.id})
# A link to the parent part
# Each part will get a reverse lookup field 'bom_items'
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items',

View File

@ -1,10 +1,11 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% 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>
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'>
<li class='list-group-item'>

View File

@ -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 %}

View File

@ -63,9 +63,9 @@
<span class='fas fa-file-download'></span> {% trans "Export" %}
</button>
{% endif %}
</div>
<div class='filter-list' id='filter-list-bom'>
<!-- Empty div (will be filled out with avilable BOM filters) -->
<div class='filter-list' id='filter-list-bom'>
<!-- Empty div (will be filled out with avilable BOM filters) -->
</div>
</div>
</div>
@ -179,8 +179,8 @@
secondary: [
{
field: 'sub_part',
label: 'New Part',
title: 'Create New Part',
label: '{% trans "New Part" %}',
title: '{% trans "Create New Part" %}',
url: "{% url 'part-create' %}",
},
]

View File

@ -49,14 +49,14 @@
{% 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 }}'>
<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>
</button>
</td>
<td></td>
<td>{% add row.index 1 %}</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'/>
</button>
<select class='select bomselect' id='select_part_{{ row.index }}' name='part_{{ row.index }}'>

View File

@ -126,9 +126,9 @@
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
</ul>
</div>
</div>
<div class='filter-list' id='filter-list-parts'>
<!-- Empty div -->
<div class='filter-list' id='filter-list-parts'>
<!-- Empty div -->
</div>
</div>
</div>

View File

@ -1,4 +1,5 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
@ -10,8 +11,8 @@
</div>
{% if matches %}
<b>Possible Matching Parts</b>
<p>The new part may be a duplicate of these existing parts:</p>
<b>{% trans "Possible Matching Parts" %}</b>
<p>{% trans "The new part may be a duplicate of these existing parts" %}:</p>
<ul class='list-group'>
{% for match in matches %}
<li class='list-group-item list-group-item-condensed'>

View File

@ -1,12 +1,13 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{{ block.super }}
<div class='alert alert-info alert-block'>
<b>Create new part variant</b><br>
Create a new variant of template <i>'{{ part.full_name }}'</i>.
<b>{% trans "Create new part variant" %}</b><br>
{% trans "Create a new variant of template" %} <i>'{{ part.full_name }}'</i>.
</div>
{% endblock %}

View File

@ -145,3 +145,22 @@ def get_color_theme_css(username):
inventree_css_static_url = os.path.join(settings.STATIC_URL, inventree_css_sheet)
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

View File

@ -99,8 +99,6 @@ part_category_urls = [
part_bom_urls = [
url(r'^edit/?', views.BomItemEdit.as_view(), name='bom-item-edit'),
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

View File

@ -2411,15 +2411,6 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
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):
""" Create view for making a new BomItem object """
model = BomItem

208
InvenTree/report/api.py Normal file
View 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'),
])),
]

View 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',
]

View File

@ -28,8 +28,10 @@ def manually_translate_file(filename, save=False):
print("For each missing translation:")
print("a) Directly enter a new tranlation in the target language")
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("")
with open(filename, 'r') as f:
@ -58,7 +60,10 @@ def manually_translate_file(filename, save=False):
print("Source:", source_line)
print("Enter translation for {t}".format(t=msgid))
translation = str(input(">"))
try:
translation = str(input(">"))
except KeyboardInterrupt:
break
if translation and len(translation) > 0:
# Update the line with the new translation
@ -71,7 +76,7 @@ def manually_translate_file(filename, save=False):
output_file.writelines(out)
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__':

View 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)

View File

@ -25,6 +25,18 @@
lft: 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'
- model: stock.stockitem
pk: 1234

View File

@ -90,7 +90,8 @@ class EditStockLocationForm(HelperForm):
fields = [
'name',
'parent',
'description'
'description',
'owner',
]
@ -138,6 +139,7 @@ class CreateStockItemForm(HelperForm):
'link',
'delete_on_deplete',
'status',
'owner',
]
# Custom clean to prevent complex StockItem.clean() logic from running (yet)
@ -414,6 +416,7 @@ class EditStockItemForm(HelperForm):
'purchase_price',
'link',
'delete_on_deplete',
'owner',
]

View 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'),
),
]

View File

@ -38,6 +38,8 @@ from InvenTree.status_codes import StockStatus
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
from users.models import Owner
from company import models as CompanyModels
from part import models as PartModels
@ -48,6 +50,10 @@ class StockLocation(InvenTreeTree):
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):
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'),
)
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):
"""
Returns True if this Stock item is "stale".

View File

@ -8,17 +8,25 @@
{% 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>
<hr>
{% if roles.stock.change %}
<div id='table-toolbar'>
<div class='btn-group'>
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>
<span class='fas fa-plus-circle'></span> {% trans "New Entry" %}
</button>
<!-- 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 class='btn-group'>
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>
<span class='fas fa-plus-circle'></span> {% trans "New Entry" %}
</button>
</div>
</div>
</div>
{% endif %}
{% endif %}
<table class='table table-condensed table-striped' id='track-table' data-toolbar='#table-toolbar'>
</table>

View File

@ -15,6 +15,17 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% block pre_content %}
{% 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 %}
<div class='alert alert-block alert-info'>
{% trans "This stock item is in production and cannot be edited." %}<br>
@ -68,6 +79,12 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% endblock %}
{% block page_data %}
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
{% if owner_control.value == "True" %}
{% authorized_owners item.owner as owners %}
{% endif %}
<h3>
{% trans "Stock Item" %}
{% if item.is_expired %}
@ -120,7 +137,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<!-- Document / label menu -->
{% if item.has_labels or item.has_test_reports %}
<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'>
{% if item.has_labels %}
<li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
@ -132,54 +149,57 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
</div>
{% endif %}
<!-- Stock adjustment menu -->
{% if roles.stock.change and not item.is_building %}
<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>
<ul class='dropdown-menu' role='menu'>
{% if item.in_stock %}
{% if not item.serialized %}
<li><a href='#' id='stock-count' title='{% trans "Count stock" %}'><span class='fas fa-clipboard-list'></span> {% trans "Count stock" %}</a></li>
<li><a href='#' id='stock-add' title='{% trans "Add stock" %}'><span class='fas fa-plus-circle icon-green'></span> {% trans "Add stock" %}</a></li>
<li><a href='#' id='stock-remove' title='{% trans "Remove stock" %}'><span class='fas fa-minus-circle icon-red'></span> {% trans "Remove stock" %}</a></li>
{% endif %}
<li><a href='#' id='stock-move' title='{% trans "Transfer stock" %}'><span class='fas fa-exchange-alt icon-blue'></span> {% trans "Transfer stock" %}</a></li>
{% if item.part.trackable and not item.serialized %}
<li><a href='#' id='stock-serialize' title='{% trans "Serialize stock" %}'><span class='fas fa-hashtag'></span> {% trans "Serialize stock" %}</a> </li>
{% endif %}
{% endif %}
{% if item.part.salable and not item.customer %}
<li><a href='#' id='stock-assign-to-customer' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
{% endif %}
{% if item.customer %}
<li><a href='#' id='stock-return-from-customer' title='{% trans "Return to stock" %}'><span class='fas fa-undo'></span> {% trans "Return to stock" %}</a></li>
{% endif %}
{% if item.belongs_to %}
<li>
<a href='#' id='stock-uninstall' title='{% trans "Uninstall stock item" %}'><span class='fas fa-unlink'></span> {% trans "Uninstall" %}</a>
</li>
{% endif %}
</ul>
</div>
{% endif %}
<!-- Edit stock item -->
{% if roles.stock.change and not item.is_building %}
<div class='btn-group'>
<button id='stock-edit-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-tools'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'>
{% if item.part.has_variants %}
<li><a href='#' id='stock-convert' title='{% trans "Convert to variant" %}'><span class='fas fa-screwdriver'></span> {% trans "Convert to variant" %}</a></li>
{% endif %}
{% if roles.stock.add %}
<li><a href='#' id='stock-duplicate' title='{% trans "Duplicate stock item" %}'><span class='fas fa-copy'></span> {% trans "Duplicate stock item" %}</a></li>
{% endif %}
<li><a href='#' id='stock-edit' title='{% trans "Edit stock item" %}'><span class='fas fa-edit icon-blue'></span> {% trans "Edit stock item" %}</a></li>
{% if user.is_staff or roles.stock.delete %}
{% if item.can_delete %}
<li><a href='#' id='stock-delete' title='{% trans "Delete stock item" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete stock item" %}</a></li>
<!-- 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 %}
<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>
<ul class='dropdown-menu' role='menu'>
{% if item.in_stock %}
{% if not item.serialized %}
<li><a href='#' id='stock-count' title='{% trans "Count stock" %}'><span class='fas fa-clipboard-list'></span> {% trans "Count stock" %}</a></li>
<li><a href='#' id='stock-add' title='{% trans "Add stock" %}'><span class='fas fa-plus-circle icon-green'></span> {% trans "Add stock" %}</a></li>
<li><a href='#' id='stock-remove' title='{% trans "Remove stock" %}'><span class='fas fa-minus-circle icon-red'></span> {% trans "Remove stock" %}</a></li>
{% endif %}
{% endif %}
</ul>
</div>
<li><a href='#' id='stock-move' title='{% trans "Transfer stock" %}'><span class='fas fa-exchange-alt icon-blue'></span> {% trans "Transfer stock" %}</a></li>
{% if item.part.trackable and not item.serialized %}
<li><a href='#' id='stock-serialize' title='{% trans "Serialize stock" %}'><span class='fas fa-hashtag'></span> {% trans "Serialize stock" %}</a> </li>
{% endif %}
{% endif %}
{% if item.part.salable and not item.customer %}
<li><a href='#' id='stock-assign-to-customer' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
{% endif %}
{% if item.customer %}
<li><a href='#' id='stock-return-from-customer' title='{% trans "Return to stock" %}'><span class='fas fa-undo'></span> {% trans "Return to stock" %}</a></li>
{% endif %}
{% if item.belongs_to %}
<li>
<a href='#' id='stock-uninstall' title='{% trans "Uninstall stock item" %}'><span class='fas fa-unlink'></span> {% trans "Uninstall" %}</a>
</li>
{% endif %}
</ul>
</div>
{% endif %}
<!-- Edit stock item -->
{% if roles.stock.change and not item.is_building %}
<div class='btn-group'>
<button id='stock-edit-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-tools'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'>
{% if item.part.has_variants %}
<li><a href='#' id='stock-convert' title='{% trans "Convert to variant" %}'><span class='fas fa-screwdriver'></span> {% trans "Convert to variant" %}</a></li>
{% endif %}
{% if roles.stock.add %}
<li><a href='#' id='stock-duplicate' title='{% trans "Duplicate stock item" %}'><span class='fas fa-copy'></span> {% trans "Duplicate stock item" %}</a></li>
{% endif %}
<li><a href='#' id='stock-edit' title='{% trans "Edit stock item" %}'><span class='fas fa-edit icon-blue'></span> {% trans "Edit stock item" %}</a></li>
{% if user.is_staff or roles.stock.delete %}
{% if item.can_delete %}
<li><a href='#' id='stock-delete' title='{% trans "Delete stock item" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete stock item" %}</a></li>
{% endif %}
{% endif %}
</ul>
</div>
{% endif %}
{% endif %}
</div>
@ -394,12 +414,7 @@ $('#stock-uninstall').click(function() {
});
$("#stock-test-report").click(function() {
launchModalForm(
"{% url 'stock-item-test-report-select' item.id %}",
{
follow: true,
}
);
printTestReports([{{ item.pk }}]);
});
$("#print-label").click(function() {

View File

@ -50,14 +50,9 @@ function reloadTable() {
//$("#test-result-table").bootstrapTable("refresh");
}
{% if item.part.has_test_report_templates %}
{% if item.has_test_reports %}
$("#test-report").click(function() {
launchModalForm(
"{% url 'stock-item-test-report-select' item.id %}",
{
follow: true,
}
);
printTestReports([{{ item.pk }}]);
});
{% endif %}

View File

@ -1,8 +1,20 @@
{% extends "stock/stock_app_base.html" %}
{% load static %}
{% load inventree_extras %}
{% load i18n %}
{% 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='col-sm-6'>
{% if location %}
@ -18,11 +30,13 @@
<p>{% trans "All stock items" %}</p>
{% endif %}
<div class='btn-group action-buttons' role='group'>
{% if roles.stock_location.add %}
<button class='btn btn-default' id='location-create' title='{% trans "Create new stock location" %}'>
<span class='fas fa-plus-circle icon-green'/>
</button>
{% endif %}
{% 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 %}
<button class='btn btn-default' id='location-create' title='{% trans "Create new stock location" %}'>
<span class='fas fa-plus-circle icon-green'/>
</button>
{% endif %}
{% endif %}
<!-- Barcode actions menu -->
{% if location %}
<div class='btn-group'>
@ -33,25 +47,28 @@
<li><a href='#' id='barcode-check-in'><span class='fas fa-arrow-right'></span> {% trans "Check-in Items" %}</a></li>
</ul>
</div>
{% if roles.stock.change %}
<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>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='location-count'><span class='fas fa-clipboard-list'></span>
{% trans "Count stock" %}</a></li>
</ul>
</div>
{% endif %}
{% if roles.stock_location.change %}
<div class='btn-group'>
<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'>
<li><a href='#' id='location-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit location" %}</a></li>
{% if roles.stock_location.delete %}
<li><a href='#' id='location-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete location" %}</a></li>
{% endif %}
</ul>
</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 %}
<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>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='location-count'><span class='fas fa-clipboard-list'></span>
{% trans "Count stock" %}</a></li>
</ul>
</div>
{% endif %}
{% if roles.stock_location.change %}
<div class='btn-group'>
<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'>
<li><a href='#' id='location-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit location" %}</a></li>
{% if roles.stock.delete %}
<li><a href='#' id='location-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete location" %}</a></li>
{% endif %}
</ul>
</div>
{% endif %}
{% endif %}
{% endif %}
</div>

View File

@ -117,7 +117,7 @@ class StockItemListTest(StockAPITestCase):
response = self.get_stock()
self.assertEqual(len(response), 19)
self.assertEqual(len(response), 20)
def test_filter_by_part(self):
"""
@ -126,7 +126,7 @@ class StockItemListTest(StockAPITestCase):
response = self.get_stock(part=25)
self.assertEqual(len(response), 7)
self.assertEqual(len(response), 8)
response = self.get_stock(part=10004)
@ -166,7 +166,7 @@ class StockItemListTest(StockAPITestCase):
self.assertEqual(len(response), 1)
response = self.get_stock(depleted=0)
self.assertEqual(len(response), 18)
self.assertEqual(len(response), 19)
def test_filter_by_in_stock(self):
"""
@ -174,7 +174,7 @@ class StockItemListTest(StockAPITestCase):
"""
response = self.get_stock(in_stock=1)
self.assertEqual(len(response), 16)
self.assertEqual(len(response), 17)
response = self.get_stock(in_stock=0)
self.assertEqual(len(response), 3)
@ -185,7 +185,7 @@ class StockItemListTest(StockAPITestCase):
"""
codes = {
StockStatus.OK: 17,
StockStatus.OK: 18,
StockStatus.DESTROYED: 1,
StockStatus.LOST: 1,
StockStatus.DAMAGED: 0,
@ -218,7 +218,7 @@ class StockItemListTest(StockAPITestCase):
self.assertIsNotNone(item['serial'])
response = self.get_stock(serialized=0)
self.assertEqual(len(response), 7)
self.assertEqual(len(response), 8)
for item in response:
self.assertIsNone(item['serial'])
@ -230,7 +230,7 @@ class StockItemListTest(StockAPITestCase):
# First, we can assume that the 'stock expiry' feature is disabled
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!
InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
@ -242,7 +242,7 @@ class StockItemListTest(StockAPITestCase):
self.assertTrue(item['expired'])
response = self.get_stock(expired=0)
self.assertEqual(len(response), 18)
self.assertEqual(len(response), 19)
for item in response:
self.assertFalse(item['expired'])
@ -259,7 +259,7 @@ class StockItemListTest(StockAPITestCase):
self.assertEqual(len(response), 4)
response = self.get_stock(expired=0)
self.assertEqual(len(response), 15)
self.assertEqual(len(response), 16)
class StockItemTest(StockAPITestCase):

View File

@ -10,6 +10,8 @@ from common.models import InvenTreeSetting
import json
from datetime import datetime, timedelta
from InvenTree.status_codes import StockStatus
class StockViewTestCase(TestCase):
@ -230,3 +232,184 @@ class StockItemTest(StockViewTestCase):
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
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)

View File

@ -29,8 +29,6 @@ stock_item_detail_urls = [
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'^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'),
@ -62,8 +60,6 @@ stock_urls = [
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
url(r'^item/attachment/', include([
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),

View File

@ -11,6 +11,8 @@ from django.views.generic import DetailView, ListView, UpdateView
from django.forms.models import model_to_dict
from django.forms import HiddenInput
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 _
@ -30,10 +32,11 @@ from datetime import datetime, timedelta
from company.models import Company, SupplierPart
from part.models import Part
from report.models import TestReport
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
import common.settings
from common.models import InvenTreeSetting
from users.models import Owner
from .admin import StockItemResource
@ -126,6 +129,7 @@ class StockLocationEdit(AjaxUpdateView):
""" Customize form data for StockLocation editing.
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()
@ -138,8 +142,105 @@ class StockLocationEdit(AjaxUpdateView):
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
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):
""" View for displaying a QR code for a StockLocation object """
@ -410,92 +511,6 @@ class StockItemTestResultDelete(AjaxDeleteView):
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):
""" Form for selecting StockExport options """
@ -1169,6 +1184,18 @@ class StockAdjust(AjaxView, FormMixin):
count += 1
# Is ownership control enabled?
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
if stock_ownership_control:
# Fetch destination owner
destination_owner = destination.owner
if destination_owner:
# Update owner
item.owner = destination_owner
item.save()
if count == 0:
return _('No items were moved')
@ -1235,8 +1262,76 @@ class StockItemEdit(AjaxUpdateView):
if not item.part.trackable and not item.serialized:
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
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):
"""
@ -1289,6 +1384,76 @@ class StockLocationCreate(AjaxCreateView):
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):
""" View for manually serializing a StockItem """
@ -1483,7 +1648,42 @@ class StockItemCreate(AjaxCreateView):
# Otherwise if the user has selected a SupplierPart, we know what Part they meant!
if form['supplier_part'].value() is not None:
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
def get_initial(self):
@ -1560,10 +1760,15 @@ class StockItemCreate(AjaxCreateView):
data = form.cleaned_data
part = data['part']
part = data.get('part', None)
quantity = data.get('quantity', None)
owner = data.get('owner', None)
if not part:
return
if not quantity:
return
@ -1582,17 +1787,31 @@ class StockItemCreate(AjaxCreateView):
sn = str(sn).strip()
if len(sn) > 0:
serials = extract_serial_numbers(sn, quantity)
try:
serials = extract_serial_numbers(sn, quantity)
except ValidationError as e:
serials = None
form.add_error('serial_numbers', e.messages)
existing = part.find_conflicting_serial_numbers(serials)
if serials is not None:
existing = part.find_conflicting_serial_numbers(serials)
if len(existing) > 0:
exists = ','.join([str(x) for x in existing])
if len(existing) > 0:
exists = ','.join([str(x) for x in existing])
form.add_error(
'serial_numbers',
_('Serial numbers already exist') + ': ' + exists
)
form.add_error(
'serial_numbers',
_('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):
"""

View File

@ -19,6 +19,7 @@
{% 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_BUILD" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_OWNERSHIP_CONTROL" %}
</tbody>
</table>
{% endblock %}
{% endblock %}

View File

@ -1,6 +1,5 @@
{% load i18n %}
{% if roles.stock.change %}
<div id='attachment-buttons'>
<div class='btn-group'>
<button type='button' class='btn btn-success' id='new-attachment'>
@ -8,7 +7,6 @@
</button>
</div>
</div>
{% endif %}
<div class='dropzone' id='attachment-dropzone'>
<table class='table table-striped table-condensed' data-toolbar='#attachment-buttons' id='attachment-table'>

View File

@ -121,6 +121,7 @@ InvenTree
<script type='text/javascript' src="{% url 'part.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 'report.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 'order.js' %}"></script>

View File

@ -214,47 +214,45 @@ function loadBomTable(table, options) {
},
});
if (!options.editable) {
cols.push(
{
field: 'sub_part_detail.stock',
title: '{% trans "Available" %}',
searchable: false,
sortable: true,
formatter: function(value, row, index, field) {
cols.push(
{
field: 'sub_part_detail.stock',
title: '{% trans "Available" %}',
searchable: false,
sortable: true,
formatter: function(value, row, index, field) {
var url = `/part/${row.sub_part_detail.pk}/stock/`;
var text = value;
var url = `/part/${row.sub_part_detail.pk}/stock/`;
var text = value;
if (value == null || value <= 0) {
text = `<span class='label label-warning'>{% trans "No Stock" %}</span>`;
}
return renderLink(text, url);
if (value == null || value <= 0) {
text = `<span class='label label-warning'>{% trans "No Stock" %}</span>`;
}
});
/*
return renderLink(text, url);
}
});
// TODO - Re-introduce the pricing column at a later stage,
// once the pricing has been "fixed"
// O.W. 2020-11-24
/*
cols.push(
{
field: 'price_range',
title: '{% trans "Price" %}',
sortable: true,
formatter: function(value, row, index, field) {
if (value) {
return value;
} else {
return "<span class='warning-msg'>{% trans "No pricing available" %}</span>";
}
// TODO - Re-introduce the pricing column at a later stage,
// once the pricing has been "fixed"
// O.W. 2020-11-24
cols.push(
{
field: 'price_range',
title: '{% trans "Price" %}',
sortable: true,
formatter: function(value, row, index, field) {
if (value) {
return value;
} else {
return "<span class='warning-msg'>{% trans "No pricing available" %}</span>";
}
});
*/
}
}
});
*/
cols.push(
{

View File

@ -133,8 +133,17 @@ function selectLabel(labels, items, options={}) {
);
// 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'>
<div class='form-group'>
<label class='control-label requiredField' for='id_label'>
@ -170,4 +179,4 @@ function selectLabel(labels, items, options={}) {
options.success(pk);
}
});
}
}

View 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;
}
}
);
}
}
);
}

View File

@ -625,9 +625,19 @@ function loadStockTable(table, options) {
],
});
/*
if (options.buttons) {
linkButtonsToSelection(table, options.buttons);
}
*/
linkButtonsToSelection(
table,
[
'#stock-print-options',
'#stock-options',
]
);
function stockAdjustment(action) {
var items = $("#stock-table").bootstrapTable("getSelections");
@ -665,6 +675,7 @@ function loadStockTable(table, options) {
}
// Automatically link button callbacks
$('#multi-item-print-label').click(function() {
var selections = $('#stock-table').bootstrapTable('getSelections');
@ -677,6 +688,18 @@ function loadStockTable(table, options) {
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() {
stockAdjustment('count');
});

View File

@ -1,23 +1,39 @@
{% 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 class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
<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>
{% 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 %}
<button class="btn btn-success" id='item-create'>
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
<button class="btn btn-success" id='item-create' title='{% trans "New Stock Item" %}'>
<span class='fas fa-plus-circle'></span>
</button>
{% 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 %}
<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">
<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 %}
<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>
@ -29,13 +45,13 @@
{% if roles.stock.delete %}
<li><a href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'><span class='fas fa-trash-alt'></span> {% trans "Delete Stock" %}</a></li>
{% endif %}
</ul>
</ul>
</div>
{% endif %}
{% endif %}
<div class='filter-list' id='filter-list-stock'>
<!-- An empty div in which the filter list will be constructed -->
</div>
{% endif %}
{% endif %}
</div>
<div class='filter-list' id='filter-list-stock'>
<!-- An empty div in which the filter list will be constructed -->
</div>
</div>
</div>

View File

@ -11,7 +11,7 @@ from django.contrib.auth.models import Group
from django.contrib.auth.admin import UserAdmin
from django.utils.safestring import mark_safe
from users.models import RuleSet
from users.models import RuleSet, Owner
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.register(Group, RoleGroupAdmin)
admin.site.unregister(User)
admin.site.register(User, InvenTreeUserAdmin)
admin.site.register(Owner, OwnerAdmin)

View File

@ -16,6 +16,11 @@ class UsersConfig(AppConfig):
except (OperationalError, ProgrammingError):
pass
try:
self.update_owners()
except (OperationalError, ProgrammingError):
pass
def assign_permissions(self):
from django.contrib.auth.models import Group
@ -31,3 +36,17 @@ class UsersConfig(AppConfig):
for group in Group.objects.all():
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)

View 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'),
),
]

View File

@ -1,12 +1,16 @@
# -*- coding: utf-8 -*-
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.fields import GenericForeignKey
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.utils.translation import gettext_lazy as _
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):
@ -116,6 +120,7 @@ class RuleSet(models.Model):
'report_reportasset',
'report_testreport',
'part_partstar',
'users_owner',
# Third-party tables
'error_report_error',
@ -350,7 +355,7 @@ def update_group_roles(group, debug=False):
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):
"""
Called *after* a Group object is saved.
@ -392,3 +397,151 @@ def check_user_role(user, role, permission):
# No matching permissions found
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()

View File

@ -3,9 +3,10 @@ from __future__ import unicode_literals
from django.test import TestCase
from django.apps import apps
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from users.models import RuleSet
from users.models import RuleSet, Owner
class RuleSetModelTest(TestCase):
@ -157,3 +158,48 @@ class RuleSetModelTest(TestCase):
# There should now not be any permissions assigned to this group
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)

View File

@ -186,6 +186,10 @@ def translate(c):
manage(c, "makemessages -e py -e html -e js")
manage(c, "compilemessages")
path = os.path.join('InvenTree', 'script', 'translation_stats.py')
c.run(f'python {path}')
@task
def style(c):
"""