Merge branch 'inventree:master' into fr-1421-sso

This commit is contained in:
Matthias Mair 2021-09-24 00:32:38 +02:00 committed by GitHub
commit 44c5003839
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 31717 additions and 27314 deletions

30
.github/ISSUE_TEMPLATE/app_issue.md vendored Normal file
View File

@ -0,0 +1,30 @@
---
name: App issue
about: Report a bug or issue with the InvenTree app
title: "[APP] Enter bug description"
labels: bug, app
assignees: ''
---
**Describe the bug**
A clear and concise description of the bug or issue
**To Reproduce**
Steps to reproduce the behavior:
1. Go to ...
2. Select ...
3. ...
**Expected Behavior**
A clear and concise description of what you expected to happen
**Screenshots**
If applicable, add screenshots to help explain your problem
**Version Information**
- App platform: *Select iOS or Android*
- App version: *Enter app version*
- Server version: *Enter server version*

View File

@ -15,6 +15,9 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Check version number
run: |
python3 ci/check_version_number.py --dev
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx

42
.github/workflows/docker_stable.yaml vendored Normal file
View File

@ -0,0 +1,42 @@
# Build and push latest docker image on push to master branch
name: Docker Build
on:
push:
branches:
- 'stable'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Check version number
run: |
python3 ci/check_version_number.py --release
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Dockerhub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push
uses: docker/build-push-action@v2
with:
context: ./docker
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
target: production
build-args:
branch: stable
repository: inventree/inventree
tags: inventree/inventree:stable
- name: Image Digest
run: echo ${{ steps.docker_build.outputs.digest }}

View File

@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v2
- name: Check Release tag
run: |
python3 ci/check_version_number.py ${{ github.event.release.tag_name }}
python3 ci/check_version_number.py --release --tag ${{ github.event.release.tag_name }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx

20
.github/workflows/version.yaml vendored Normal file
View File

@ -0,0 +1,20 @@
# Check that the version number format matches the current branch
name: Version Numbering
on:
pull_request:
branches-ignore:
- l10*
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Check version number
run: |
python3 ci/check_version_number.py --branch ${{ github.base_ref }}

View File

@ -1,22 +1,102 @@
Contributions to InvenTree are welcomed - please follow the guidelines below.
Please read the contribution guidelines below, before submitting your first pull request to the InvenTree codebase.
## Feature Branches
## Branches and Versioning
No pushing to master! New featues must be submitted in a separate branch (one branch per feature).
InvenTree roughly follow the [GitLab flow](https://docs.gitlab.com/ee/topics/gitlab_flow.html) branching style, to allow simple management of multiple tagged releases, short-lived branches, and development on the main branch.
## Include Migration Files
### Version Numbering
InvenTree version numbering follows the [semantic versioning](https://semver.org/) specification.
### Master Branch
The HEAD of the "main" or "master" branch of InvenTree represents the current "latest" state of code development.
- All feature branches are merged into master
- All bug fixes are merged into master
**No pushing to master:** New featues must be submitted as a pull request from a separate branch (one branch per feature).
#### Feature Branches
Feature branches should be branched *from* the *master* branch.
- One major feature per branch / pull request
- Feature pull requests are merged back *into* the master branch
- Features *may* also be merged into a release candidate branch
### Stable Branch
The HEAD of the "stable" branch represents the latest stable release code.
- Versioned releases are merged into the "stable" branch
- Bug fix branches are made *from* the "stable" branch
#### Release Candidate Branches
- Release candidate branches are made from master, and merged into stable.
- RC branches are targetted at a major/minor version e.g. "0.5"
- When a release candidate branch is merged into *stable*, the release is tagged
#### Bugfix Branches
- If a bug is discovered in a tagged release version of InvenTree, a "bugfix" or "hotfix" branch should be made *from* that tagged release
- When approved, the branch is merged back *into* stable, with an incremented PATCH number (e.g. 0.4.1 -> 0.4.2)
- The bugfix *must* also be cherry picked into the *master* branch.
## Migration Files
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `invoke migrate` and commit the migration files before submitting the PR.
*Note: A github action checks for unstaged migration files and will reject the PR if it finds any!*
## Testing
## Unit Testing
Any new code should be covered by unit tests - a submitted PR may not be accepted if the code coverage is decreased.
Any new code should be covered by unit tests - a submitted PR may not be accepted if the code coverage for any new features is insufficient, or the overall code coverage is decreased.
The InvenTree code base makes use of [GitHub actions](https://github.com/features/actions) to run a suite of automated tests against the code base every time a new pull request is received. These actions include (but are not limited to):
- Checking Python and Javascript code against standard style guides
- Running unit test suite
- Automated building and pushing of docker images
- Generating translation files
The various github actions can be found in the `./github/workflows` directory
## Code Style
Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR.
## Documentation
New features or updates to existing features should be accompanied by user documentation. A PR with associated documentation should link to the matching PR at https://github.com/inventree/inventree-docs/
## Code Style
## Translations
Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR.
Any user-facing strings *must* be passed through the translation engine.
- InvenTree code is written in English
- User translatable strings are provided in English as the primary language
- Secondary language translations are provided [via Crowdin](https://crowdin.com/project/inventree)
*Note: Translation files are updated via GitHub actions - you do not need to compile translations files before submitting a pull request!*
### Python Code
For strings exposed via Python code, use the following format:
```python
from django.utils.translation import ugettext_lazy as _
user_facing_string = _('This string will be exposed to the translation engine!')
```
### Templated Strings
HTML and javascript files are passed through the django templating engine. Translatable strings are implemented as follows:
```html
{% load i18n %}
<span>{% trans "This string will be translated" %} - this string will not!</span>
```

View File

@ -63,6 +63,13 @@ class InvenTreeConfig(AppConfig):
schedule_type=Schedule.DAILY,
)
# Delete "old" stock items
InvenTree.tasks.schedule_task(
'stock.tasks.delete_old_stock_items',
schedule_type=Schedule.MINUTES,
minutes=30,
)
def update_exchange_rates(self):
"""
Update exchange rates each time the server is started, *if*:

View File

@ -36,9 +36,14 @@ def health_status(request):
'email_configured': InvenTree.status.is_email_configured(),
}
# The following keys are required to denote system health
health_keys = [
'django_q_running',
]
all_healthy = True
for k in status.keys():
for k in health_keys:
if status[k] is not True:
all_healthy = False

View File

@ -0,0 +1 @@
{"de": 95, "el": 0, "en": 0, "es": 4, "fr": 6, "he": 0, "id": 0, "it": 0, "ja": 4, "ko": 0, "nl": 0, "no": 0, "pl": 27, "ru": 6, "sv": 0, "th": 0, "tr": 32, "vi": 0, "zh": 1}

View File

@ -98,10 +98,12 @@ class InvenTreeMetadata(SimpleMetadata):
serializer_info = super().get_serializer_info(serializer)
try:
ModelClass = serializer.Meta.model
model_class = None
model_fields = model_meta.get_field_info(ModelClass)
try:
model_class = serializer.Meta.model
model_fields = model_meta.get_field_info(model_class)
# Iterate through simple fields
for name, field in model_fields.fields.items():
@ -146,11 +148,23 @@ class InvenTreeMetadata(SimpleMetadata):
if hasattr(serializer, 'instance'):
instance = serializer.instance
if instance is None:
try:
instance = self.view.get_object()
except:
pass
if instance is None and model_class is not None:
# Attempt to find the instance based on kwargs lookup
kwargs = getattr(self.view, 'kwargs', None)
if kwargs:
pk = None
for field in ['pk', 'id', 'PK', 'ID']:
if field in kwargs:
pk = kwargs[field]
break
if pk is not None:
try:
instance = model_class.objects.get(pk=pk)
except (ValueError, model_class.DoesNotExist):
pass
if instance is not None:
"""

View File

@ -1061,3 +1061,7 @@ input[type='number']{
.search-menu .ui-menu-item {
margin-top: 0.5rem;
}
.product-card-panel{
height: 100%;
}

View File

@ -8,13 +8,16 @@ import re
import common.models
INVENTREE_SW_VERSION = "0.5.0 pre"
INVENTREE_SW_VERSION = "0.5.0 dev"
INVENTREE_API_VERSION = 11
INVENTREE_API_VERSION = 12
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v12 -> 2021-09-07
- Adds API endpoint to receive stock items against a PurchaseOrder
v11 -> 2021-08-26
- Adds "units" field to PartBriefSerializer
- This allows units to be introspected from the "part_detail" field in the StockItem serializer
@ -67,7 +70,7 @@ def inventreeInstanceTitle():
def inventreeVersion():
""" Returns the InvenTree version string """
return INVENTREE_SW_VERSION
return INVENTREE_SW_VERSION.lower().strip()
def inventreeVersionTuple(version=None):
@ -81,6 +84,33 @@ def inventreeVersionTuple(version=None):
return [int(g) for g in match.groups()]
def isInvenTreeDevelopmentVersion():
"""
Return True if current InvenTree version is a "development" version
"""
print("is dev?", inventreeVersion())
return inventreeVersion().endswith('dev')
def inventreeDocsVersion():
"""
Return the version string matching the latest documentation.
Development -> "latest"
Release -> "major.minor"
"""
if isInvenTreeDevelopmentVersion():
return "latest"
else:
major, minor, patch = inventreeVersionTuple()
return f"{major}.{minor}"
def isInvenTreeUpToDate():
"""
Test if the InvenTree instance is "up to date" with the latest version.

View File

@ -8,8 +8,9 @@ from django.db.utils import IntegrityError
from InvenTree import status_codes as status
from build.models import Build, BuildItem, get_next_build_number
from stock.models import StockItem
from part.models import Part, BomItem
from stock.models import StockItem
from stock.tasks import delete_old_stock_items
class BuildTest(TestCase):
@ -352,6 +353,11 @@ class BuildTest(TestCase):
# the original BuildItem objects should have been deleted!
self.assertEqual(BuildItem.objects.count(), 0)
self.assertEqual(StockItem.objects.count(), 8)
# Clean up old stock items
delete_old_stock_items()
# New stock items should have been created!
self.assertEqual(StockItem.objects.count(), 7)

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

@ -5,12 +5,16 @@ JSON API for the Order app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include
from django.db import transaction
from django_filters import rest_framework as rest_filters
from rest_framework import generics
from rest_framework import filters, status
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import str2bool
@ -28,6 +32,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
from .models import SalesOrderAttachment
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
from .serializers import SalesOrderAllocationSerializer
from .serializers import POReceiveSerializer
class POList(generics.ListCreateAPIView):
@ -205,6 +210,111 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView):
return queryset
class POReceive(generics.CreateAPIView):
"""
API endpoint to receive stock items against a purchase order.
- The purchase order is specified in the URL.
- Items to receive are specified as a list called "items" with the following options:
- supplier_part: pk value of the supplier part
- quantity: quantity to receive
- status: stock item status
- location: destination for stock item (optional)
- A global location can also be specified
"""
queryset = PurchaseOrderLineItem.objects.none()
serializer_class = POReceiveSerializer
def get_serializer_context(self):
context = super().get_serializer_context()
# Pass the purchase order through to the serializer for validation
context['order'] = self.get_order()
return context
def get_order(self):
"""
Returns the PurchaseOrder associated with this API endpoint
"""
pk = self.kwargs.get('pk', None)
if pk is None:
return None
else:
order = PurchaseOrder.objects.get(pk=self.kwargs['pk'])
return order
def create(self, request, *args, **kwargs):
# Which purchase order are we receiving against?
self.order = self.get_order()
# Validate the serialized data
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Receive the line items
self.receive_items(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@transaction.atomic
def receive_items(self, serializer):
"""
Receive the items
At this point, much of the heavy lifting has been done for us by DRF serializers!
We have a list of "items", each a dict which contains:
- line_item: A PurchaseOrderLineItem matching this order
- location: A destination location
- quantity: A validated numerical quantity
- status: The status code for the received item
"""
data = serializer.validated_data
location = data['location']
items = data['items']
# Check if the location is not specified for any particular item
for item in items:
line = item['line_item']
if not item.get('location', None):
# If a global location is specified, use that
item['location'] = location
if not item['location']:
# The line item specifies a location?
item['location'] = line.get_destination()
if not item['location']:
raise ValidationError({
'location': _("Destination location must be specified"),
})
# Now we can actually receive the items
for item in items:
self.order.receive_line_item(
item['line_item'],
item['location'],
item['quantity'],
self.request.user,
status=item['status'],
barcode=item.get('barcode', ''),
)
class POLineItemList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of POLineItem objects
@ -641,13 +751,25 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
order_api_urls = [
# API endpoints for purchase orders
url(r'po/attachment/', include([
url(r'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'),
url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'),
url(r'^po/', include([
# Purchase order attachments
url(r'attachment/', include([
url(r'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'),
url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'),
])),
# Individual purchase order detail URLs
url(r'^(?P<pk>\d+)/', include([
url(r'^receive/', POReceive.as_view(), name='api-po-receive'),
url(r'.*$', PODetail.as_view(), name='api-po-detail'),
])),
# Purchase order list
url(r'^.*$', POList.as_view(), name='api-po-list'),
])),
url(r'^po/(?P<pk>\d+)/$', PODetail.as_view(), name='api-po-detail'),
url(r'^po/.*$', POList.as_view(), name='api-po-list'),
# API endpoints for purchase order line items
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),

View File

@ -411,6 +411,11 @@ class PurchaseOrder(Order):
"""
notes = kwargs.get('notes', '')
barcode = kwargs.get('barcode', '')
# Prevent null values for barcode
if barcode is None:
barcode = ''
if not self.status == PurchaseOrderStatus.PLACED:
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
@ -433,7 +438,8 @@ class PurchaseOrder(Order):
quantity=quantity,
purchase_order=self,
status=status,
purchase_price=purchase_price,
purchase_price=line.purchase_price,
uid=barcode
)
stock.save(add_note=False)

View File

@ -12,6 +12,8 @@ from django.db.models import Case, When, Value
from django.db.models import BooleanField, ExpressionWrapper, F
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount
from InvenTree.serializers import InvenTreeModelSerializer
@ -19,8 +21,13 @@ from InvenTree.serializers import InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeMoneySerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField
from InvenTree.status_codes import StockStatus
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from part.serializers import PartBriefSerializer
import stock.models
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
from .models import PurchaseOrder, PurchaseOrderLineItem
@ -137,7 +144,6 @@ class POLineItemSerializer(InvenTreeModelSerializer):
self.fields.pop('part_detail')
self.fields.pop('supplier_part_detail')
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0)
@ -182,6 +188,131 @@ class POLineItemSerializer(InvenTreeModelSerializer):
]
class POLineItemReceiveSerializer(serializers.Serializer):
"""
A serializer for receiving a single purchase order line item against a purchase order
"""
line_item = serializers.PrimaryKeyRelatedField(
queryset=PurchaseOrderLineItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Line Item'),
)
def validate_line_item(self, item):
if item.order != self.context['order']:
raise ValidationError(_('Line item does not match purchase order'))
return item
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('Location'),
help_text=_('Select destination location for received items'),
)
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
required=True,
)
status = serializers.ChoiceField(
choices=list(StockStatus.items()),
default=StockStatus.OK,
label=_('Status'),
)
barcode = serializers.CharField(
label=_('Barcode Hash'),
help_text=_('Unique identifier field'),
default='',
required=False,
)
def validate_barcode(self, barcode):
"""
Cannot check in a LineItem with a barcode that is already assigned
"""
# Ignore empty barcode values
if not barcode or barcode.strip() == '':
return
if stock.models.StockItem.objects.filter(uid=barcode).exists():
raise ValidationError(_('Barcode is already in use'))
return barcode
class Meta:
fields = [
'barcode',
'line_item',
'location',
'quantity',
'status',
]
class POReceiveSerializer(serializers.Serializer):
"""
Serializer for receiving items against a purchase order
"""
items = POLineItemReceiveSerializer(many=True)
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
many=False,
allow_null=True,
label=_('Location'),
help_text=_('Select destination location for received items'),
)
def is_valid(self, raise_exception=False):
super().is_valid(raise_exception)
# Custom validation
data = self.validated_data
items = data.get('items', [])
if len(items) == 0:
self._errors['items'] = _('Line items must be provided')
else:
# Ensure barcodes are unique
unique_barcodes = set()
for item in items:
barcode = item.get('barcode', '')
if barcode:
if barcode in unique_barcodes:
self._errors['items'] = _('Supplied barcode values must be unique')
break
else:
unique_barcodes.add(barcode)
if self._errors and raise_exception:
raise ValidationError(self.errors)
return not bool(self._errors)
class Meta:
fields = [
'items',
'location',
]
class POAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
Serializers for the PurchaseOrderAttachment model

View File

@ -38,7 +38,7 @@
<h4>{% trans "Received Items" %}</h4>
</div>
<div class='panel-content'>
{% include "stock_table.html" with read_only=True %}
{% include "stock_table.html" with prevent_new_stock=True %}
</div>
</div>
@ -201,257 +201,32 @@ $('#new-po-line').click(function() {
},
method: 'POST',
title: '{% trans "Add Line Item" %}',
onSuccess: reloadTable,
onSuccess: function() {
$('#po-line-table').bootstrapTable('refresh');
},
});
});
{% endif %}
function reloadTable() {
$("#po-line-table").bootstrapTable("refresh");
}
function setupCallbacks() {
// Setup callbacks for the line buttons
var table = $("#po-line-table");
loadPurchaseOrderLineItemTable('#po-line-table', {
order: {{ order.pk }},
supplier: {{ order.supplier.pk }},
{% if order.status == PurchaseOrderStatus.PENDING %}
table.find(".button-line-edit").click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/po-line/${pk}/`, {
fields: {
part: {
filters: {
part_detail: true,
supplier_detail: true,
supplier: {{ order.supplier.pk }},
}
},
quantity: {},
reference: {},
purchase_price: {},
purchase_price_currency: {},
destination: {},
notes: {},
},
title: '{% trans "Edit Line Item" %}',
onSuccess: reloadTable,
});
});
table.find(".button-line-delete").click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/po-line/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Line Item" %}',
onSuccess: reloadTable,
});
});
allow_edit: true,
{% else %}
allow_edit: false,
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
allow_receive: true,
{% else %}
allow_receive: false,
{% endif %}
table.find(".button-line-receive").click(function() {
var pk = $(this).attr('pk');
launchModalForm("{% url 'po-receive' order.id %}", {
success: reloadTable,
data: {
line: pk,
},
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
});
}
$("#po-line-table").inventreeTable({
onPostBody: setupCallbacks,
name: 'purchaseorderlines',
sidePagination: 'server',
formatNoMatches: function() { return "{% trans 'No line items found' %}"; },
queryParams: {
order: {{ order.id }},
part_detail: true,
},
url: "{% url 'api-po-line-list' %}",
showFooter: true,
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'part',
sortable: true,
sortName: 'part_name',
title: '{% trans "Part" %}',
switchable: false,
formatter: function(value, row, index, field) {
if (row.part) {
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`);
} else {
return '-';
}
},
footerFormatter: function() {
return '{% trans "Total" %}'
}
},
{
field: 'part_detail.description',
title: '{% trans "Description" %}',
},
{
sortable: true,
sortName: 'SKU',
field: 'supplier_part_detail.SKU',
title: '{% trans "SKU" %}',
formatter: function(value, row, index, field) {
if (value) {
return renderLink(value, `/supplier-part/${row.part}/`);
} else {
return '-';
}
},
},
{
sortable: true,
sortName: 'MPN',
field: 'supplier_part_detail.manufacturer_part_detail.MPN',
title: '{% trans "MPN" %}',
formatter: function(value, row, index, field) {
if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) {
return renderLink(value, `/manufacturer-part/${row.supplier_part_detail.manufacturer_part}/`);
} else {
return "-";
}
},
},
{
sortable: true,
field: 'reference',
title: '{% trans "Reference" %}',
},
{
sortable: true,
field: 'quantity',
title: '{% trans "Quantity" %}',
footerFormatter: function(data) {
return data.map(function (row) {
return +row['quantity']
}).reduce(function (sum, i) {
return sum + i
}, 0)
}
},
{
sortable: true,
field: 'purchase_price',
title: '{% trans "Unit Price" %}',
formatter: function(value, row) {
return row.purchase_price_string || row.purchase_price;
}
},
{
field: 'total_price',
sortable: true,
field: 'total_price',
title: '{% trans "Total price" %}',
formatter: function(value, row) {
var total = row.purchase_price * row.quantity;
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.purchase_price_currency});
return formatter.format(total)
},
footerFormatter: function(data) {
var total = data.map(function (row) {
return +row['purchase_price']*row['quantity']
}).reduce(function (sum, i) {
return sum + i
}, 0)
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
return formatter.format(total)
}
},
{
sortable: false,
field: 'received',
switchable: false,
title: '{% trans "Received" %}',
formatter: function(value, row, index, field) {
return makeProgressBar(row.received, row.quantity, {
id: `order-line-progress-${row.pk}`,
});
},
sorter: function(valA, valB, rowA, rowB) {
if (rowA.received == 0 && rowB.received == 0) {
return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
var progressA = parseFloat(rowA.received) / rowA.quantity;
var progressB = parseFloat(rowB.received) / rowB.quantity;
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'destination',
title: '{% trans "Destination" %}',
formatter: function(value, row) {
if (value) {
return renderLink(row.destination_detail.pathstring, `/stock/location/${value}/`);
} else {
return '-';
}
}
},
{
field: 'notes',
title: '{% trans "Notes" %}',
},
{
switchable: false,
field: 'buttons',
title: '',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group'>`;
var pk = row.pk;
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.delete %}
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
if (row.received < row.quantity) {
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
}
{% endif %}
html += `</div>`;
return html;
},
}
]
});
attachNavCallbacks({
name: 'purchase-order',
default: 'order-items'
});
attachNavCallbacks({
name: 'purchase-order',
default: 'order-items'
});
{% endblock %}

View File

@ -9,8 +9,11 @@ from rest_framework import status
from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import PurchaseOrderStatus
from .models import PurchaseOrder, SalesOrder
from stock.models import StockItem
from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder
class OrderTest(InvenTreeAPITestCase):
@ -201,6 +204,250 @@ class PurchaseOrderTest(OrderTest):
response = self.get(url, expected_code=404)
class PurchaseOrderReceiveTest(OrderTest):
"""
Unit tests for receiving items against a PurchaseOrder
"""
def setUp(self):
super().setUp()
self.assignRole('purchase_order.add')
self.url = reverse('api-po-receive', kwargs={'pk': 1})
# Number of stock items which exist at the start of each test
self.n = StockItem.objects.count()
# Mark the order as "placed" so we can receive line items
order = PurchaseOrder.objects.get(pk=1)
order.status = PurchaseOrderStatus.PLACED
order.save()
def test_empty(self):
"""
Test without any POST data
"""
data = self.post(self.url, {}, expected_code=400).data
self.assertIn('This field is required', str(data['items']))
self.assertIn('This field is required', str(data['location']))
# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())
def test_no_items(self):
"""
Test with an empty list of items
"""
data = self.post(
self.url,
{
"items": [],
"location": None,
},
expected_code=400
).data
self.assertIn('Line items must be provided', str(data['items']))
# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())
def test_invalid_items(self):
"""
Test than errors are returned as expected for invalid data
"""
data = self.post(
self.url,
{
"items": [
{
"line_item": 12345,
"location": 12345
}
]
},
expected_code=400
).data
items = data['items'][0]
self.assertIn('Invalid pk "12345"', str(items['line_item']))
self.assertIn("object does not exist", str(items['location']))
# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())
def test_invalid_status(self):
"""
Test with an invalid StockStatus value
"""
data = self.post(
self.url,
{
"items": [
{
"line_item": 22,
"location": 1,
"status": 99999,
"quantity": 5,
}
]
},
expected_code=400
).data
self.assertIn('"99999" is not a valid choice.', str(data))
# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())
def test_mismatched_items(self):
"""
Test for supplier parts which *do* exist but do not match the order supplier
"""
data = self.post(
self.url,
{
'items': [
{
'line_item': 22,
'quantity': 123,
'location': 1,
}
],
'location': None,
},
expected_code=400
).data
self.assertIn('Line item does not match purchase order', str(data))
# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())
def test_invalid_barcodes(self):
"""
Tests for checking in items with invalid barcodes:
- Cannot check in "duplicate" barcodes
- Barcodes cannot match UID field for existing StockItem
"""
# Set stock item barcode
item = StockItem.objects.get(pk=1)
item.uid = 'MY-BARCODE-HASH'
item.save()
response = self.post(
self.url,
{
'items': [
{
'line_item': 1,
'quantity': 50,
'barcode': 'MY-BARCODE-HASH',
}
],
'location': 1,
},
expected_code=400
)
self.assertIn('Barcode is already in use', str(response.data))
response = self.post(
self.url,
{
'items': [
{
'line_item': 1,
'quantity': 5,
'barcode': 'MY-BARCODE-HASH-1',
},
{
'line_item': 1,
'quantity': 5,
'barcode': 'MY-BARCODE-HASH-1'
},
],
'location': 1,
},
expected_code=400
)
self.assertIn('barcode values must be unique', str(response.data))
# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())
def test_valid(self):
"""
Test receipt of valid data
"""
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
self.assertEqual(line_1.received, 0)
self.assertEqual(line_2.received, 50)
# Receive two separate line items against this order
self.post(
self.url,
{
'items': [
{
'line_item': 1,
'quantity': 50,
'barcode': 'MY-UNIQUE-BARCODE-123',
},
{
'line_item': 2,
'quantity': 200,
'location': 2, # Explicit location
'barcode': 'MY-UNIQUE-BARCODE-456',
}
],
'location': 1, # Default location
},
expected_code=201,
)
# There should be two newly created stock items
self.assertEqual(self.n + 2, StockItem.objects.count())
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
self.assertEqual(line_1.received, 50)
self.assertEqual(line_2.received, 250)
stock_1 = StockItem.objects.filter(supplier_part=line_1.part)
stock_2 = StockItem.objects.filter(supplier_part=line_2.part)
# 1 new stock item created for each supplier part
self.assertEqual(stock_1.count(), 1)
self.assertEqual(stock_2.count(), 1)
# Different location for each received item
self.assertEqual(stock_1.last().location.pk, 1)
self.assertEqual(stock_2.last().location.pk, 2)
# Barcodes should have been assigned to the stock items
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists())
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists())
class SalesOrderTest(OrderTest):
"""
Tests for the SalesOrder API

View File

@ -667,6 +667,8 @@
});
onPanelLoad("test-templates", function() {
// Load test template table
loadPartTestTemplateTable(
$("#test-template-table"),
{
@ -677,11 +679,8 @@
}
);
// Callback for "add test template" button
$("#add-test-template").click(function() {
function reloadTestTemplateTable() {
$("#test-template-table").bootstrapTable("refresh");
}
constructForm('{% url "api-part-test-template-list" %}', {
method: 'POST',
@ -697,39 +696,10 @@
}
},
title: '{% trans "Add Test Result Template" %}',
onSuccess: reloadTestTemplateTable
onSuccess: function() {
$("#test-template-table").bootstrapTable("refresh");
}
});
$("#test-template-table").on('click', '.button-test-edit', function() {
var pk = $(this).attr('pk');
var url = `/api/part/test-template/${pk}/`;
constructForm(url, {
fields: {
test_name: {},
description: {},
required: {},
requires_value: {},
requires_attachment: {},
},
title: '{% trans "Edit Test Result Template" %}',
onSuccess: reloadTestTemplateTable,
});
});
$("#test-template-table").on('click', '.button-test-delete', function() {
var pk = $(this).attr('pk');
var url = `/api/part/test-template/${pk}/`;
constructForm(url, {
method: 'DELETE',
title: '{% trans "Delete Test Result Template" %}',
onSuccess: reloadTestTemplateTable,
});
});
});
});

View File

@ -136,6 +136,21 @@ def inventree_version(*args, **kwargs):
return version.inventreeVersion()
@register.simple_tag()
def inventree_is_development(*args, **kwargs):
return version.isInvenTreeDevelopmentVersion()
@register.simple_tag()
def inventree_is_release(*args, **kwargs):
return not version.isInvenTreeDevelopmentVersion()
@register.simple_tag()
def inventree_docs_version(*args, **kwargs):
return version.inventreeDocsVersion()
@register.simple_tag()
def inventree_api_version(*args, **kwargs):
""" Return InvenTree API version """
@ -169,7 +184,10 @@ def inventree_github_url(*args, **kwargs):
@register.simple_tag()
def inventree_docs_url(*args, **kwargs):
""" Return URL for InvenTree documenation site """
return "https://inventree.readthedocs.io/"
tag = version.inventreeDocsVersion()
return f"https://inventree.readthedocs.io/en/{tag}"
@register.simple_tag()

View File

@ -588,6 +588,27 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(new_part.supplier_parts.count(), 1)
self.assertEqual(new_part.manufacturer_parts.count(), 1)
def test_strange_chars(self):
"""
Test that non-standard ASCII chars are accepted
"""
url = reverse('api-part-list')
name = "Kaltgerätestecker"
description = "Gerät"
data = {
"name": name,
"description": description,
"category": 2
}
response = self.post(url, data, expected_code=201)
self.assertEqual(response.data['name'], name)
self.assertEqual(response.data['description'], description)
class PartDetailTests(InvenTreeAPITestCase):
"""

View File

@ -653,6 +653,9 @@ class StockList(generics.ListCreateAPIView):
queryset = StockItemSerializer.annotate_queryset(queryset)
# Do not expose StockItem objects which are scheduled for deletion
queryset = queryset.filter(scheduled_for_deletion=False)
return queryset
def filter_queryset(self, queryset):

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.4 on 2021-09-07 06:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0065_auto_20210701_0509'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='scheduled_for_deletion',
field=models.BooleanField(default=False, help_text='This StockItem will be deleted by the background worker', verbose_name='Scheduled for deletion'),
),
]

View File

@ -209,12 +209,18 @@ class StockItem(MPTTModel):
belongs_to=None,
customer=None,
is_building=False,
status__in=StockStatus.AVAILABLE_CODES
status__in=StockStatus.AVAILABLE_CODES,
scheduled_for_deletion=False,
)
# A query filter which can be used to filter StockItem objects which have expired
EXPIRED_FILTER = IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=datetime.now().date())
def mark_for_deletion(self):
self.scheduled_for_deletion = True
self.save()
def save(self, *args, **kwargs):
"""
Save this StockItem to the database. Performs a number of checks:
@ -588,6 +594,12 @@ class StockItem(MPTTModel):
help_text=_('Select Owner'),
related_name='stock_items')
scheduled_for_deletion = models.BooleanField(
default=False,
verbose_name=_('Scheduled for deletion'),
help_text=_('This StockItem will be deleted by the background worker'),
)
def is_stale(self):
"""
Returns True if this Stock item is "stale".
@ -1294,9 +1306,8 @@ class StockItem(MPTTModel):
self.quantity = quantity
if quantity == 0 and self.delete_on_deplete and self.can_delete():
self.mark_for_deletion()
# TODO - Do not actually "delete" stock at this point - instead give it a "DELETED" flag
self.delete()
return False
else:
self.save()

35
InvenTree/stock/tasks.py Normal file
View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import logging
from django.core.exceptions import AppRegistryNotReady
logger = logging.getLogger('inventree')
def delete_old_stock_items():
"""
This function removes StockItem objects which have been marked for deletion.
Bulk "delete" operations for database entries with foreign-key relationships
can be pretty expensive, and thus can "block" the UI for a period of time.
Thus, instead of immediately deleting multiple StockItems, some UI actions
simply mark each StockItem as "scheduled for deletion".
The background worker then manually deletes these at a later stage
"""
try:
from stock.models import StockItem
except AppRegistryNotReady:
logger.info("Could not delete scheduled StockItems - AppRegistryNotReady")
return
items = StockItem.objects.filter(scheduled_for_deletion=True)
if items.count() > 0:
logger.info(f"Removing {items.count()} StockItem objects scheduled for deletion")
items.delete()

View File

@ -332,6 +332,8 @@ class StockTest(TestCase):
w1 = StockItem.objects.get(pk=100)
w2 = StockItem.objects.get(pk=101)
self.assertFalse(w2.scheduled_for_deletion)
# Take 25 units from w1 (there are only 10 in stock)
w1.take_stock(30, None, notes='Took 30')
@ -342,6 +344,16 @@ class StockTest(TestCase):
# Take 25 units from w2 (will be deleted)
w2.take_stock(30, None, notes='Took 30')
# w2 should now be marked for future deletion
w2 = StockItem.objects.get(pk=101)
self.assertTrue(w2.scheduled_for_deletion)
from stock.tasks import delete_old_stock_items
# Now run the "background task" to delete these stock items
delete_old_stock_items()
# This StockItem should now have been deleted
with self.assertRaises(StockItem.DoesNotExist):
w2 = StockItem.objects.get(pk=101)

View File

@ -22,13 +22,39 @@
<td>{% trans "InvenTree Version" %}</td>
<td>
<a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a>{% include "clip.html" %}
{% inventree_is_development as dev %}
{% if dev %}
<span class='label label-blue float-right'>{% trans "Development Version" %}</span>
{% else %}
{% if up_to_date %}
<span class='label label-green float-right'>{% trans "Up to Date" %}</span>
{% else %}
<span class='label label-red float-right'>{% trans "Update Available" %}</span>
{% endif %}
{% endif %}
</td>
</tr>
{% if dev %}
{% inventree_commit_hash as hash %}
{% if hash %}
<tr>
<td><span class='fas fa-code-branch'></span></td>
<td>{% trans "Commit Hash" %}</td><td>{{ hash }}{% include "clip.html" %}</td>
</tr>
{% endif %}
{% inventree_commit_date as commit_date %}
{% if commit_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Commit Date" %}</td><td>{{ commit_date }}{% include "clip.html" %}</td>
</tr>
{% endif %}
{% endif %}
<tr>
<td><span class='fas fa-book'></span></td>
<td>{% trans "InvenTree Documentation" %}</td>
<td><a href="{% inventree_docs_url %}">{% inventree_docs_url %}</a></td>
</tr>
<tr>
<td><span class='fas fa-code'></span></td>
<td>{% trans "API Version" %}</td>
@ -44,25 +70,6 @@
<td>{% trans "Django Version" %}</td>
<td><a href="https://www.djangoproject.com/">{% django_version %}</a>{% include "clip.html" %}</td>
</tr>
{% inventree_commit_hash as hash %}
{% if hash %}
<tr>
<td><span class='fas fa-code-branch'></span></td>
<td>{% trans "Commit Hash" %}</td><td>{{ hash }}{% include "clip.html" %}</td>
</tr>
{% endif %}
{% inventree_commit_date as commit_date %}
{% if commit_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Commit Date" %}</td><td>{{ commit_date }}{% include "clip.html" %}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-book'></span></td>
<td>{% trans "InvenTree Documentation" %}</td>
<td><a href="{% inventree_docs_url %}">{% inventree_docs_url %}</a></td>
</tr>
<tr>
<td><span class='fab fa-github'></span></td>
<td>{% trans "View Code on GitHub" %}</td>

View File

@ -286,6 +286,8 @@ function constructForm(url, options) {
constructFormBody({}, options);
}
options.fields = options.fields || {};
// Save the URL
options.url = url;
@ -545,6 +547,11 @@ function constructFormBody(fields, options) {
initializeGroups(fields, options);
if (options.afterRender) {
// Custom callback function after form rendering
options.afterRender(fields, options);
}
// Scroll to the top
$(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
}
@ -1542,7 +1549,9 @@ function constructField(name, parameters, options) {
html += `<div id='div_${field_name}' class='${form_classes}'>`;
// Add a label
html += constructLabel(name, parameters);
if (!options.hideLabels) {
html += constructLabel(name, parameters);
}
html += `<div class='controls'>`;
@ -1589,7 +1598,7 @@ function constructField(name, parameters, options) {
html += `</div>`; // input-group
}
if (parameters.help_text) {
if (parameters.help_text && !options.hideLabels) {
html += constructHelpText(name, parameters, options);
}

View File

@ -10,6 +10,7 @@
makeProgressBar,
renderLink,
select2Thumbnail,
thumbnailImage
yesNoLabel,
*/
@ -56,6 +57,26 @@ function imageHoverIcon(url) {
}
/**
* Renders a simple thumbnail image
* @param {String} url is the image URL
* @returns html <img> tag
*/
function thumbnailImage(url) {
if (!url) {
url = '/static/img/blank_img.png';
}
// TODO: Support insertion of custom classes
var html = `<img class='hover-img-thumb' src='${url}'>`;
return html;
}
// Render a select2 thumbnail image
function select2Thumbnail(image) {
if (!image) {

View File

@ -793,14 +793,25 @@ function attachSecondaries(modal, secondaries) {
function insertActionButton(modal, options) {
/* Insert a custom submission button */
var html = `
<span style='float: right;'>
<button name='${options.name}' type='submit' class='btn btn-default modal-form-button' value='${options.name}'>
${options.title}
</button>
</span>`;
var element = $(modal).find('#modal-footer-buttons');
$(modal).find('#modal-footer-buttons').append(html);
// check if button already present
var already_present = false;
for (var child=element[0].firstElementChild; child; child=child.nextElementSibling) {
if (item.firstElementChild.name == options.name) {
already_present = true;
}
}
if (already_present == false) {
var html = `
<span style='float: right;'>
<button name='${options.name}' type='submit' class='btn btn-default modal-form-button' value='${options.name}'>
${options.title}
</button>
</span>`;
element.append(html);
}
}
function attachButtons(modal, buttons) {

View File

@ -20,6 +20,7 @@
/* exported
createSalesOrder,
editPurchaseOrderLineItem,
loadPurchaseOrderLineItemTable,
loadPurchaseOrderTable,
loadSalesOrderAllocationTable,
loadSalesOrderTable,
@ -144,7 +145,6 @@ function newSupplierPartFromOrderWizard(e) {
if (!part) {
part = $(src).closest('button').attr('part');
console.log('parent: ' + part);
}
createSupplierPart({
@ -367,6 +367,271 @@ function loadPurchaseOrderTable(table, options) {
});
}
/**
* Load a table displaying line items for a particular PurchasesOrder
* @param {String} table - HTML ID tag e.g. '#table'
* @param {Object} options - options which must provide:
* - order (integer PK)
* - supplier (integer PK)
* - allow_edit (boolean)
* - allow_receive (boolean)
*/
function loadPurchaseOrderLineItemTable(table, options={}) {
function setupCallbacks() {
if (options.allow_edit) {
$(table).find('.button-line-edit').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/po-line/${pk}/`, {
fields: {
part: {
filters: {
part_detail: true,
supplier_detail: true,
supplier: options.supplier,
}
},
quantity: {},
reference: {},
purchase_price: {},
purchase_price_currency: {},
destination: {},
notes: {},
},
title: '{% trans "Edit Line Item" %}',
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
});
$(table).find('.button-line-delete').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/po-line/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Line Item" %}',
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
});
}
if (options.allow_receive) {
$(table).find('.button-line-receive').click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/purchase-order/${options.order}/receive/`, {
success: function() {
$(table).bootstrapTable('refresh');
},
data: {
line: pk,
},
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: '{% url "stock-location-create" %}',
},
]
});
});
}
}
$(table).inventreeTable({
onPostBody: setupCallbacks,
name: 'purchaseorderlines',
sidePagination: 'server',
formatNoMatches: function() {
return '{% trans "No line items found" %}';
},
queryParams: {
order: options.order,
part_detail: true
},
url: '{% url "api-po-line-list" %}',
showFooter: true,
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'part',
sortable: true,
sortName: 'part_name',
title: '{% trans "Part" %}',
switchable: false,
formatter: function(value, row, index, field) {
if (row.part) {
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`);
} else {
return '-';
}
},
footerFormatter: function() {
return '{% trans "Total" %}';
}
},
{
field: 'part_detail.description',
title: '{% trans "Description" %}',
},
{
sortable: true,
sortName: 'SKU',
field: 'supplier_part_detail.SKU',
title: '{% trans "SKU" %}',
formatter: function(value, row, index, field) {
if (value) {
return renderLink(value, `/supplier-part/${row.part}/`);
} else {
return '-';
}
},
},
{
sortable: true,
sortName: 'MPN',
field: 'supplier_part_detail.manufacturer_part_detail.MPN',
title: '{% trans "MPN" %}',
formatter: function(value, row, index, field) {
if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) {
return renderLink(value, `/manufacturer-part/${row.supplier_part_detail.manufacturer_part}/`);
} else {
return '-';
}
},
},
{
sortable: true,
field: 'reference',
title: '{% trans "Reference" %}',
},
{
sortable: true,
field: 'quantity',
title: '{% trans "Quantity" %}',
footerFormatter: function(data) {
return data.map(function(row) {
return +row['quantity'];
}).reduce(function(sum, i) {
return sum + i;
}, 0);
}
},
{
sortable: true,
field: 'purchase_price',
title: '{% trans "Unit Price" %}',
formatter: function(value, row) {
return row.purchase_price_string || row.purchase_price;
}
},
{
field: 'total_price',
sortable: true,
field: 'total_price',
title: '{% trans "Total price" %}',
formatter: function(value, row) {
var total = row.purchase_price * row.quantity;
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.purchase_price_currency});
return formatter.format(total);
},
footerFormatter: function(data) {
var total = data.map(function(row) {
return +row['purchase_price']*row['quantity'];
}).reduce(function(sum, i) {
return sum + i;
}, 0);
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
var formatter = new Intl.NumberFormat(
'en-US',
{
style: 'currency',
currency: currency
}
);
return formatter.format(total);
}
},
{
sortable: false,
field: 'received',
switchable: false,
title: '{% trans "Received" %}',
formatter: function(value, row, index, field) {
return makeProgressBar(row.received, row.quantity, {
id: `order-line-progress-${row.pk}`,
});
},
sorter: function(valA, valB, rowA, rowB) {
if (rowA.received == 0 && rowB.received == 0) {
return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
var progressA = parseFloat(rowA.received) / rowA.quantity;
var progressB = parseFloat(rowB.received) / rowB.quantity;
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'destination',
title: '{% trans "Destination" %}',
formatter: function(value, row) {
if (value) {
return renderLink(row.destination_detail.pathstring, `/stock/location/${value}/`);
} else {
return '-';
}
}
},
{
field: 'notes',
title: '{% trans "Notes" %}',
},
{
switchable: false,
field: 'buttons',
title: '',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group'>`;
var pk = row.pk;
if (options.allow_edit) {
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
}
if (options.allow_receive && row.received < row.quantity) {
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
}
html += `</div>`;
return html;
},
}
]
});
}
function loadSalesOrderTable(table, options) {
options.params = options.params || {};

View File

@ -768,7 +768,7 @@ function partGridTile(part) {
var html = `
<div class='col-sm-3 card'>
<div class='panel panel-default panel-inventree'>
<div class='panel panel-default panel-inventree product-card-panel'>
<div class='panel-heading'>
<a href='/part/${part.pk}/'>
<b>${part.full_name}</b>
@ -1007,7 +1007,7 @@ function loadPartTable(table, url, options={}) {
// Force a new row every 4 columns, to prevent visual issues
if ((index > 0) && (index % 4 == 0) && (index < data.length)) {
html += `</div><div class='row'>`;
html += `</div><div class='row full-height'>`;
}
html += partGridTile(row);
@ -1252,7 +1252,43 @@ function loadPartTestTemplateTable(table, options) {
}
}
}
]
],
onPostBody: function() {
table.find('.button-test-edit').click(function() {
var pk = $(this).attr('pk');
var url = `/api/part/test-template/${pk}/`;
constructForm(url, {
fields: {
test_name: {},
description: {},
required: {},
requires_value: {},
requires_attachment: {},
},
title: '{% trans "Edit Test Result Template" %}',
onSuccess: function() {
table.bootstrapTable('refresh');
},
});
});
table.find('.button-test-delete').click(function() {
var pk = $(this).attr('pk');
var url = `/api/part/test-template/${pk}/`;
constructForm(url, {
method: 'DELETE',
title: '{% trans "Delete Test Result Template" %}',
onSuccess: function() {
table.bootstrapTable('refresh');
},
});
});
}
});
}

View File

@ -71,11 +71,7 @@
<a class='dropdown-toggle' data-toggle='dropdown' href="#">
{% if user.is_staff %}
{% if not system_healthy %}
{% if not django_q_running %}
<span class='fas fa-exclamation-triangle icon-red'></span>
{% else %}
<span class='fas fa-exclamation-triangle icon-orange'></span>
{% endif %}
{% elif not up_to_date %}
<span class='fas fa-info-circle icon-green'></span>
{% endif %}
@ -96,11 +92,7 @@
{% if system_healthy or not user.is_staff %}
<span class='fas fa-server'></span>
{% else %}
{% if not django_q_running %}
<span class='fas fa-server icon-red'></span>
{% else %}
<span class='fas fa-server icon-orange'></span>
{% endif %}
<span class='fas fa-server icon-red'></span>
{% endif %}
</span> {% trans "System Information" %}
</a></li>

View File

@ -16,7 +16,7 @@
</button>
{% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %}
{% if not read_only and roles.stock.add %}
{% if not read_only and not prevent_new_stock and roles.stock.add %}
<button class="btn btn-success" id='item-create' title='{% trans "New Stock Item" %}'>
<span class='fas fa-plus-circle'></span>
</button>

View File

@ -28,9 +28,9 @@ InvenTree is [available via Docker](https://hub.docker.com/r/inventree/inventree
InvenTree is supported by a [companion mobile app](https://inventree.readthedocs.io/en/latest/app/app/) which allows users access to stock control information and functionality.
[**Download InvenTree from the Android Play Store**](https://play.google.com/store/apps/details?id=inventree.inventree_app)
- [**Download InvenTree from the Android Play Store**](https://play.google.com/store/apps/details?id=inventree.inventree_app)
*Currently the mobile app is only availble for Android*
- [**Download InvenTree from the Apple App Store**](https://apps.apple.com/au/app/inventree/id1581731101#?platform=iphone)
# Translation

View File

@ -27,12 +27,61 @@ if __name__ == '__main__':
version = results[0]
parser = argparse.ArgumentParser()
parser.add_argument('tag', help='Version tag', action='store')
parser.add_argument('-t', '--tag', help='Compare against specified version tag', action='store')
parser.add_argument('-r', '--release', help='Check that this is a release version', action='store_true')
parser.add_argument('-d', '--dev', help='Check that this is a development version', action='store_true')
parser.add_argument('-b', '--branch', help='Check against a particular branch', action='store')
args = parser.parse_args()
if not args.tag == version:
print(f"Release tag '{args.tag}' does not match INVENTREE_SW_VERSION '{version}'")
sys.exit(1)
if args.branch:
"""
Version number requirement depends on format of branch
'master': development branch
'stable': release branch
"""
print(f"Checking version number for branch '{args.branch}'")
if args.branch == 'master':
print("- This is a development branch")
args.dev = True
elif args.branch == 'stable':
print("- This is a stable release branch")
args.release = True
if args.dev:
"""
Check that the current verrsion number matches the "development" format
e.g. "0.5 dev"
"""
pattern = "^\d+(\.\d+)+ dev$"
result = re.match(pattern, version)
if result is None:
print(f"Version number '{version}' does not match required pattern for development branch")
sys.exit(1)
elif args.release:
"""
Check that the current version number matches the "release" format
e.g. "0.5.1"
"""
pattern = "^\d+(\.\d+)+$"
result = re.match(pattern, version)
if result is None:
print(f"Version number '{version}' does not match required pattern for stable branch")
sys.exit(1)
if args.tag:
if not args.tag == version:
print(f"Release tag '{args.tag}' does not match INVENTREE_SW_VERSION '{version}'")
sys.exit(1)
sys.exit(0)

View File

@ -1,7 +1,8 @@
# Django framework
Django==3.2.4 # Django package
Django==3.2.5 # Django package
gunicorn>=20.1.0 # Gunicorn web server
pillow==8.2.0 # Image manipulation
pillow==8.3.2 # Image manipulation
djangorestframework==3.12.4 # DRF framework
django-cors-headers==3.2.0 # CORS headers extension for DRF
django-filter==2.4.0 # Extended filtering options