mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 03:00:54 +00:00
Merge remote-tracking branch 'inventree/master' into batch-reports
# Conflicts: # InvenTree/templates/stock_table.html
This commit is contained in:
@ -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
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
25
InvenTree/stock/migrations/0057_stock_location_item_owner.py
Normal file
25
InvenTree/stock/migrations/0057_stock_location_item_owner.py
Normal file
@ -0,0 +1,25 @@
|
||||
# Generated by Django 3.0.7 on 2021-01-11 21:54
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0005_owner_model'),
|
||||
('stock', '0056_stockitem_expiry_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, help_text='Select Owner', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='users.Owner'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='stocklocation',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, help_text='Select Owner', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_locations', to='users.Owner'),
|
||||
),
|
||||
]
|
@ -38,6 +38,8 @@ from InvenTree.status_codes import StockStatus
|
||||
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
||||
from InvenTree.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".
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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 _
|
||||
|
||||
@ -33,6 +35,8 @@ from part.models import Part
|
||||
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
|
||||
|
||||
@ -125,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()
|
||||
@ -137,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 """
|
||||
@ -1082,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')
|
||||
|
||||
@ -1148,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):
|
||||
"""
|
||||
@ -1202,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 """
|
||||
@ -1396,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):
|
||||
@ -1473,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
|
||||
|
||||
@ -1512,6 +1804,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 and not self.request.user.is_superuser:
|
||||
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.
|
||||
|
Reference in New Issue
Block a user