diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 5c04f9a7e9..d5fa135b09 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -189,6 +189,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'), diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml index 45a5f5dd7f..00d3920205 100644 --- a/InvenTree/stock/fixtures/stock.yaml +++ b/InvenTree/stock/fixtures/stock.yaml @@ -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 diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index ec7cbf7805..7659981ecd 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -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', ] diff --git a/InvenTree/stock/migrations/0057_auto_20210107_1904.py b/InvenTree/stock/migrations/0057_auto_20210107_1904.py new file mode 100644 index 0000000000..9d2f2a533c --- /dev/null +++ b/InvenTree/stock/migrations/0057_auto_20210107_1904.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.7 on 2021-01-07 19:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('stock', '0056_stockitem_expiry_date'), + ] + + operations = [ + migrations.AddField( + model_name='stockitem', + name='owner', + field=models.ForeignKey(blank=True, help_text='Owner (User)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owner_stockitems', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='stocklocation', + name='owner', + field=models.ForeignKey(blank=True, help_text='Owner (Group)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owner_stocklocations', to='auth.Group'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 807d6644ca..acd505171c 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -16,7 +16,7 @@ from django.db import models, transaction from django.db.models import Sum, Q from django.db.models.functions import Coalesce from django.core.validators import MinValueValidator -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Group from django.db.models.signals import pre_delete from django.dispatch import receiver @@ -47,6 +47,10 @@ class StockLocation(InvenTreeTree): Stock locations can be heirarchical as required """ + owner = models.ForeignKey(Group, on_delete=models.SET_NULL, blank=True, null=True, + help_text='Owner (Group)', + related_name='owner_stocklocations') + def get_absolute_url(self): return reverse('stock-location-detail', kwargs={'pk': self.id}) @@ -474,6 +478,10 @@ class StockItem(MPTTModel): help_text=_('Single unit purchase price at time of purchase'), ) + owner = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, + help_text='Owner (User)', + related_name='owner_stockitems') + def is_stale(self): """ Returns True if this Stock item is "stale". diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 8f8502af2a..1082eef397 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -8,17 +8,22 @@ {% include "stock/tabs.html" with tab="tracking" %} +{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} +

{% trans "Stock Tracking Information" %}


-{% if roles.stock.change %} -
-
- + +{% if owner_control.value == "False" or owner_control.value == "True" and item.owner == user %} + {% if roles.stock.change and not item.is_building %} +
+
+ +
-
+ {% endif %} {% endif %}
diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index bea7057351..48c65d65bf 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -15,6 +15,8 @@ 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 item.is_building %}
{% trans "This stock item is in production and cannot be edited." %}
@@ -29,6 +31,12 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% endif %} +{% if owner_control.value == "True" and not item.owner == user and not user.is_superuser %} +
+ {% trans "You are not the owner of this item. This stock item cannot be edited." %}
+
+{% endif %} + {% if item.hasRequiredTests and not item.passedAllRequiredTests %}
{% trans "This stock item has not passed all required tests" %} @@ -68,6 +76,9 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {% endblock %} {% block page_data %} + +{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} +

{% trans "Stock Item" %} {% if item.is_expired %} @@ -132,54 +143,57 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}

{% endif %} - {% if roles.stock.change and not item.is_building %} -
- - -
- {% endif %} - - {% if roles.stock.change and not item.is_building %} -
- - +
+ {% endif %} + + {% if roles.stock.change and not item.is_building %} +
+ + +
+ {% endif %} {% endif %}
diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index fef3428373..6c47a697ca 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -1,8 +1,17 @@ {% extends "stock/stock_app_base.html" %} {% load static %} +{% load inventree_extras %} {% load i18n %} {% block content %} +{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} + +{% if location and owner_control.value == "True" and not location.owner in user.groups.all and not user.is_superuser %} +
+ {% trans "You are not in the list of owners of this location. This stock location cannot be edited." %}
+
+{% endif %} +
{% if location %} @@ -18,11 +27,13 @@

{% trans "All stock items" %}

{% endif %}
- {% if roles.stock.add %} - - {% endif %} + {% if owner_control.value == "False" or owner_control.value == "True" and location.owner in user.groups.all or user.is_superuser or not location %} + {% if roles.stock.add %} + + {% endif %} + {% endif %} {% if location %}
@@ -33,23 +44,26 @@
  • {% trans "Check-in Items" %}
  • - {% if roles.stock.change %} - -
    - - -
    + + {% if owner_control.value == "False" or owner_control.value == "True" and location.owner in user.groups.all or user.is_superuser %} + {% if roles.stock.change %} + +
    + + +
    + {% endif %} {% endif %} {% endif %}
    diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index b842de3836..278a4ddf5d 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -10,6 +10,9 @@ from common.models import InvenTreeSetting import json from datetime import datetime, timedelta +from common.models import InvenTreeSetting +from InvenTree.status_codes import StockStatus + class StockViewTestCase(TestCase): @@ -230,3 +233,163 @@ 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 + + user_group = self.user.groups.all()[0] + new_user_group = self.new_user.groups.all()[0] + + 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.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': self.user.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.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) + + # Test item edit + response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), + {'part': 1, 'status': StockStatus.OK, 'owner': self.new_user.pk}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertContains(response, '"form_valid": false', status_code=200) + + # Make sure the item's owner is unchanged + item = StockItem.objects.get(pk=test_item_id) + self.assertEqual(item.owner, self.user) + + # Create new parent location + parent_location = { + 'name': 'John Desk', + 'description': 'John\'s desk', + 'owner': new_user_group.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.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.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'] = self.user.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'] = self.new_user.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) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index ab6f64fb44..1af0585c39 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -11,6 +11,7 @@ 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.models import User from django.utils.translation import ugettext as _ @@ -35,6 +36,7 @@ from label.models import StockItemLabel from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult import common.settings +from common.models import InvenTreeSetting from .admin import StockItemResource @@ -127,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() @@ -139,8 +142,33 @@ 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: + form.fields['owner'].widget = HiddenInput() + else: + if location.parent: + form.fields['owner'].initial = location.parent.owner + if not self.request.user.is_superuser: + form.fields['owner'].disabled = True + return form + def save(self, object, form, **kwargs): + """ If location has children and ownership control is enabled: + - update all children's owners with location's owner + """ + + self.object = form.save() + + stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') + if self.object.get_children() and stock_ownership_control: + for child in self.object.get_children(): + child.owner = self.object.owner + child.save() + + return self.object + class StockLocationQRCode(QRCodeView): """ View for displaying a QR code for a StockLocation object """ @@ -1322,8 +1350,31 @@ 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: + if location: + # Check if location has owner + if location.owner: + form.fields['owner'].queryset = User.objects.filter(groups=location.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 not owner and stock_ownership_control: + form.add_error('owner', _('Owner is required (ownership control is enabled)')) + class StockItemConvert(AjaxUpdateView): """ @@ -1376,6 +1427,72 @@ 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: + 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: + 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: + 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 """ @@ -1570,7 +1687,29 @@ 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: + if location: + # Check if location has owner + if location.owner: + queryset = User.objects.filter(groups=location.owner) + if self.request.user in queryset: + form.fields['owner'].initial = self.request.user + form.fields['owner'].queryset = queryset + return form def get_initial(self): @@ -1647,10 +1786,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 @@ -1681,6 +1825,15 @@ class StockItemCreate(AjaxCreateView): _('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: + form.add_error('owner', _('Owner is required (ownership control is enabled)')) + return + def save(self, form, **kwargs): """ Create a new StockItem based on the provided form data. diff --git a/InvenTree/templates/InvenTree/settings/stock.html b/InvenTree/templates/InvenTree/settings/stock.html index 5ad308decc..588f01e0e9 100644 --- a/InvenTree/templates/InvenTree/settings/stock.html +++ b/InvenTree/templates/InvenTree/settings/stock.html @@ -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" %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/InvenTree/templates/attachment_table.html b/InvenTree/templates/attachment_table.html index d13b7b33b1..35b114cc05 100644 --- a/InvenTree/templates/attachment_table.html +++ b/InvenTree/templates/attachment_table.html @@ -1,6 +1,5 @@ {% load i18n %} -{% if roles.stock.change %}
    -{% endif %}
    diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html index 51f7c277db..9746ff0aaa 100644 --- a/InvenTree/templates/stock_table.html +++ b/InvenTree/templates/stock_table.html @@ -1,4 +1,7 @@ {% load i18n %} +{% load inventree_extras %} + +{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
    @@ -8,28 +11,31 @@ {% if read_only %} {% else %} - {% if roles.stock.add %} - - {% endif %} - {% if roles.stock.change or roles.stock.delete %} -
    - - -
    - {% endif %} + {% endif %} {% endif %}