mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into fr-1421-sso
This commit is contained in:
commit
44c5003839
30
.github/ISSUE_TEMPLATE/app_issue.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/app_issue.md
vendored
Normal 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*
|
@ -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
42
.github/workflows/docker_stable.yaml
vendored
Normal 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 }}
|
@ -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
20
.github/workflows/version.yaml
vendored
Normal 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 }}
|
@ -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>
|
||||
```
|
@ -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*:
|
||||
|
@ -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
|
||||
|
||||
|
1
InvenTree/InvenTree/locale_stats.json
Normal file
1
InvenTree/InvenTree/locale_stats.json
Normal 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}
|
@ -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:
|
||||
"""
|
||||
|
@ -1061,3 +1061,7 @@ input[type='number']{
|
||||
.search-menu .ui-menu-item {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.product-card-panel{
|
||||
height: 100%;
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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
@ -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'),
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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 %}
|
@ -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
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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):
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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
35
InvenTree/stock/tasks.py
Normal 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()
|
@ -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)
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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 || {};
|
||||
|
@ -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');
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user