Merge pull request #2050 from inventree/0.5.0

0.5.0
This commit is contained in:
Oliver 2021-10-01 13:23:13 +10:00 committed by GitHub
commit 0b5a4efef6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
181 changed files with 37320 additions and 30691 deletions

View File

@ -1,9 +0,0 @@
[run]
source = ./InvenTree
omit =
InvenTree/manage.py
InvenTree/setup.py
InvenTree/InvenTree/middleware.py
InvenTree/InvenTree/utils.py
InvenTree/InvenTree/wsgi.py
InvenTree/users/apps.py

25
.eslintrc.yml Normal file
View File

@ -0,0 +1,25 @@
env:
commonjs: false
browser: true
es2021: true
jquery: true
extends:
- google
parserOptions:
ecmaVersion: 12
rules:
no-var: off
guard-for-in: off
no-trailing-spaces: off
camelcase: off
padded-blocks: off
prefer-const: off
max-len: off
require-jsdoc: off
valid-jsdoc: off
no-multiple-empty-lines: off
comma-dangle: off
prefer-spread: off
indent:
- error
- 4

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*

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,31 @@
---
name: Bug report
about: Create a bug report to help us improve InvenTree
title: "[BUG] Enter bug description"
labels: bug, question
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Deployment Method**
Docker
Bare Metal
**Version Information**
You can get this by going to the "About InvenTree" section in the upper right corner and cicking on to the "copy version information"

View File

@ -0,0 +1,26 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FR]"
labels: enhancement
assignees: ''
---
**Is your feature request the result of a bug?**
Please link it here.
**Problem**
A clear and concise description of what the problem is. e.g. I'm always frustrated when [...]
**Suggested solution**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Examples of other systems**
Show how other software handles your FR if you have examples.
**Do you want to develop this?**
If so please describe briefly how you would like to implement it (so we can give advice) and if you have experience in the needed technology (you do not need to be a pro - this is just as a information for us).

View File

@ -15,6 +15,9 @@ jobs:
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Check version number
run: |
python3 ci/check_version_number.py --dev
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx - 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 uses: actions/checkout@v2
- name: Check Release tag - name: Check Release tag
run: | 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 - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx - name: Set up Docker Buildx
@ -32,5 +32,7 @@ jobs:
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true push: true
target: production target: production
build-args:
tag: ${{ github.event.release.tag_name }}
repository: inventree/inventree repository: inventree/inventree
tags: inventree/inventree:${{ github.event.release.tag_name }} tags: inventree/inventree:${{ github.event.release.tag_name }}

54
.github/workflows/html.yaml vendored Normal file
View File

@ -0,0 +1,54 @@
# Check javascript template files
name: HTML Templates
on:
push:
branches:
- master
pull_request:
branches-ignore:
- l10*
jobs:
html:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: inventree
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- name: Install node.js
uses: actions/setup-node@v2
- run: npm install
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
invoke static
- name: Check HTML Files
run: |
npm install markuplint
npx markuplint InvenTree/build/templates/build/*.html
npx markuplint InvenTree/common/templates/common/*.html
npx markuplint InvenTree/company/templates/company/*.html
npx markuplint InvenTree/order/templates/order/*.html
npx markuplint InvenTree/part/templates/part/*.html
npx markuplint InvenTree/stock/templates/stock/*.html
npx markuplint InvenTree/templates/*.html
npx markuplint InvenTree/templates/InvenTree/*.html
npx markuplint InvenTree/templates/InvenTree/settings/*.html

View File

@ -18,11 +18,33 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: inventree
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps: steps:
- name: Install node.js
uses: actions/setup-node@v2
- run: npm install
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Check Files - name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
invoke static
- name: Check Templated Files
run: | run: |
cd ci cd ci
python check_js_templates.py python check_js_templates.py
- name: Lint Javascript Files
run: |
npm install eslint eslint-config-google
invoke render-js-files
npx eslint js_tmp/*.js

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

9
.gitignore vendored
View File

@ -37,6 +37,7 @@ local_settings.py
# Files used for testing # Files used for testing
dummy_image.* dummy_image.*
_tmp.csv
# Sphinx files # Sphinx files
docs/_build docs/_build
@ -66,8 +67,16 @@ secret_key.txt
.coverage .coverage
htmlcov/ htmlcov/
# Temporary javascript files (used for testing)
js_tmp/
# Development files # Development files
dev/ dev/
# Locale stats file # Locale stats file
locale_stats.json locale_stats.json
# node.js
package-lock.json
package.json
node_modules/

View File

@ -1,29 +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. 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.
## Update Translation Files *Note: A github action checks for unstaged migration files and will reject the PR if it finds any!*
Any PRs which update translatable strings (i.e. text strings that will appear in the web-front UI) must also update the translation (locale) files to include hooks for the translated strings. ## Unit Testing
*This does not mean that all translations must be provided, but that the translation files must include locations for the translated strings to be written.* 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.
To perform this step, simply run `invoke translate` from the top level directory before submitting the PR. 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):
## Testing - Checking Python and Javascript code against standard style guides
- Running unit test suite
- Automated building and pushing of docker images
- Generating translation files
Any new code should be covered by unit tests - a submitted PR may not be accepted if the code coverage is decreased. 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 ## 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/ 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

@ -32,27 +32,44 @@ class InvenTreeConfig(AppConfig):
logger.info("Starting background tasks...") logger.info("Starting background tasks...")
# Remove successful task results from the database
InvenTree.tasks.schedule_task( InvenTree.tasks.schedule_task(
'InvenTree.tasks.delete_successful_tasks', 'InvenTree.tasks.delete_successful_tasks',
schedule_type=Schedule.DAILY, schedule_type=Schedule.DAILY,
) )
# Check for InvenTree updates
InvenTree.tasks.schedule_task( InvenTree.tasks.schedule_task(
'InvenTree.tasks.check_for_updates', 'InvenTree.tasks.check_for_updates',
schedule_type=Schedule.DAILY schedule_type=Schedule.DAILY
) )
# Heartbeat to let the server know the background worker is running
InvenTree.tasks.schedule_task( InvenTree.tasks.schedule_task(
'InvenTree.tasks.heartbeat', 'InvenTree.tasks.heartbeat',
schedule_type=Schedule.MINUTES, schedule_type=Schedule.MINUTES,
minutes=15 minutes=15
) )
# Keep exchange rates up to date
InvenTree.tasks.schedule_task( InvenTree.tasks.schedule_task(
'InvenTree.tasks.update_exchange_rates', 'InvenTree.tasks.update_exchange_rates',
schedule_type=Schedule.DAILY, schedule_type=Schedule.DAILY,
) )
# Remove expired sessions
InvenTree.tasks.schedule_task(
'InvenTree.tasks.delete_expired_sessions',
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): def update_exchange_rates(self):
""" """
Update exchange rates each time the server is started, *if*: Update exchange rates each time the server is started, *if*:

View File

@ -0,0 +1,100 @@
"""
Pull rendered copies of the templated
"""
from django.http import response
from django.test import TestCase, testcases
from django.contrib.auth import get_user_model
import os
import pathlib
class RenderJavascriptFiles(TestCase):
"""
A unit test to "render" javascript files.
The server renders templated javascript files,
we need the fully-rendered files for linting and static tests.
"""
def setUp(self):
user = get_user_model()
self.user = user.objects.create_user(
username='testuser',
password='testpassword',
email='user@gmail.com',
)
self.client.login(username='testuser', password='testpassword')
def download_file(self, filename, prefix):
url = os.path.join(prefix, filename)
response = self.client.get(url)
here = os.path.abspath(os.path.dirname(__file__))
output_dir = os.path.join(
here,
'..',
'..',
'js_tmp',
)
output_dir = os.path.abspath(output_dir)
if not os.path.exists(output_dir):
os.mkdir(output_dir)
output_file = os.path.join(
output_dir,
filename,
)
with open(output_file, 'wb') as output:
output.write(response.content)
def download_files(self, subdir, prefix):
here = os.path.abspath(os.path.dirname(__file__))
js_template_dir = os.path.join(
here,
'..',
'templates',
'js',
)
directory = os.path.join(js_template_dir, subdir)
directory = os.path.abspath(directory)
js_files = pathlib.Path(directory).rglob('*.js')
n = 0
for f in js_files:
js = os.path.basename(f)
self.download_file(js, prefix)
n += 1
return n
def test_render_files(self):
"""
Look for all javascript files
"""
n = 0
print("Rendering javascript files...")
n += self.download_files('translated', '/js/i18n')
n += self.download_files('dynamic', '/js/dynamic')
print(f"Rendered {n} javascript files.")

View File

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

View File

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from rest_framework.filters import OrderingFilter
class InvenTreeOrderingFilter(OrderingFilter):
"""
Custom OrderingFilter class which allows aliased filtering of related fields.
To use, simply specify this filter in the "filter_backends" section.
filter_backends = [
InvenTreeOrderingFilter,
]
Then, specify a ordering_field_aliases attribute:
ordering_field_alises = {
'name': 'part__part__name',
'SKU': 'part__SKU',
}
"""
def get_ordering(self, request, queryset, view):
ordering = super().get_ordering(request, queryset, view)
aliases = getattr(view, 'ordering_field_aliases', None)
# Attempt to map ordering fields based on provided aliases
if ordering is not None and aliases is not None:
"""
Ordering fields should be mapped to separate fields
"""
for idx, field in enumerate(ordering):
reverse = False
if field.startswith('-'):
field = field[1:]
reverse = True
if field in aliases:
ordering[idx] = aliases[field]
if reverse:
ordering[idx] = '-' + ordering[idx]
return ordering

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) serializer_info = super().get_serializer_info(serializer)
try: model_class = None
ModelClass = serializer.Meta.model
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 # Iterate through simple fields
for name, field in model_fields.fields.items(): for name, field in model_fields.fields.items():
@ -146,10 +148,22 @@ class InvenTreeMetadata(SimpleMetadata):
if hasattr(serializer, 'instance'): if hasattr(serializer, 'instance'):
instance = serializer.instance instance = serializer.instance
if instance is None: 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: try:
instance = self.view.get_object() instance = model_class.objects.get(pk=pk)
except: except (ValueError, model_class.DoesNotExist):
pass pass
if instance is not None: if instance is not None:

View File

@ -5,8 +5,10 @@ Generic models which provide extra functionality over base Django model types.
from __future__ import unicode_literals from __future__ import unicode_literals
import os import os
import logging
from django.db import models from django.db import models
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -21,6 +23,9 @@ from mptt.exceptions import InvalidMove
from .validators import validate_tree_name from .validators import validate_tree_name
logger = logging.getLogger('inventree')
def rename_attachment(instance, filename): def rename_attachment(instance, filename):
""" """
Function for renaming an attachment file. Function for renaming an attachment file.
@ -77,6 +82,72 @@ class InvenTreeAttachment(models.Model):
def basename(self): def basename(self):
return os.path.basename(self.attachment.name) return os.path.basename(self.attachment.name)
@basename.setter
def basename(self, fn):
"""
Function to rename the attachment file.
- Filename cannot be empty
- Filename cannot contain illegal characters
- Filename must specify an extension
- Filename cannot match an existing file
"""
fn = fn.strip()
if len(fn) == 0:
raise ValidationError(_('Filename must not be empty'))
attachment_dir = os.path.join(
settings.MEDIA_ROOT,
self.getSubdir()
)
old_file = os.path.join(
settings.MEDIA_ROOT,
self.attachment.name
)
new_file = os.path.join(
settings.MEDIA_ROOT,
self.getSubdir(),
fn
)
new_file = os.path.abspath(new_file)
# Check that there are no directory tricks going on...
if not os.path.dirname(new_file) == attachment_dir:
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
raise ValidationError(_("Invalid attachment directory"))
# Ignore further checks if the filename is not actually being renamed
if new_file == old_file:
return
forbidden = ["'", '"', "#", "@", "!", "&", "^", "<", ">", ":", ";", "/", "\\", "|", "?", "*", "%", "~", "`"]
for c in forbidden:
if c in fn:
raise ValidationError(_(f"Filename contains illegal character '{c}'"))
if len(fn.split('.')) < 2:
raise ValidationError(_("Filename missing extension"))
if not os.path.exists(old_file):
logger.error(f"Trying to rename attachment '{old_file}' which does not exist")
return
if os.path.exists(new_file):
raise ValidationError(_("Attachment with this filename already exists"))
try:
os.rename(old_file, new_file)
self.attachment.name = os.path.join(self.getSubdir(), fn)
self.save()
except:
raise ValidationError(_("Error renaming file"))
class Meta: class Meta:
abstract = True abstract = True

View File

@ -10,6 +10,8 @@ import os
from decimal import Decimal from decimal import Decimal
from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
@ -46,10 +48,12 @@ class InvenTreeMoneySerializer(MoneyField):
amount = None amount = None
try: try:
if amount is not None: if amount is not None and amount is not empty:
amount = Decimal(amount) amount = Decimal(amount)
except: except:
raise ValidationError(_("Must be a valid number")) raise ValidationError({
self.field_name: _("Must be a valid number")
})
currency = data.get(get_currency_field_name(self.field_name), self.default_currency) currency = data.get(get_currency_field_name(self.field_name), self.default_currency)
@ -93,8 +97,13 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
# If instance is None, we are creating a new instance # If instance is None, we are creating a new instance
if instance is None and data is not empty: if instance is None and data is not empty:
# Required to side-step immutability of a QueryDict if data is None:
data = data.copy() data = OrderedDict()
else:
new_data = OrderedDict()
new_data.update(data)
data = new_data
# Add missing fields which have default values # Add missing fields which have default values
ModelClass = self.Meta.model ModelClass = self.Meta.model
@ -167,6 +176,18 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
return self.instance return self.instance
def update(self, instance, validated_data):
"""
Catch any django ValidationError, and re-throw as a DRF ValidationError
"""
try:
instance = super().update(instance, validated_data)
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=serializers.as_serializer_error(exc))
return instance
def run_validation(self, data=empty): def run_validation(self, data=empty):
""" """
Perform serializer validation. Perform serializer validation.
@ -188,7 +209,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
# Update instance fields # Update instance fields
for attr, value in data.items(): for attr, value in data.items():
try:
setattr(instance, attr, value) setattr(instance, attr, value)
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=serializers.as_serializer_error(exc))
# Run a 'full_clean' on the model. # Run a 'full_clean' on the model.
# Note that by default, DRF does *not* perform full model validation! # Note that by default, DRF does *not* perform full model validation!
@ -208,6 +232,22 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
return data return data
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
"""
Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
The only real addition here is that we support "renaming" of the attachment file.
"""
# The 'filename' field must be present in the serializer
filename = serializers.CharField(
label=_('Filename'),
required=False,
source='basename',
allow_blank=False,
)
class InvenTreeAttachmentSerializerField(serializers.FileField): class InvenTreeAttachmentSerializerField(serializers.FileField):
""" """
Override the DRF native FileField serializer, Override the DRF native FileField serializer,

View File

@ -169,6 +169,30 @@ else:
logger.exception(f"Couldn't load keyfile {key_file}") logger.exception(f"Couldn't load keyfile {key_file}")
sys.exit(-1) sys.exit(-1)
# The filesystem location for served static files
STATIC_ROOT = os.path.abspath(
get_setting(
'INVENTREE_STATIC_ROOT',
CONFIG.get('static_root', None)
)
)
if STATIC_ROOT is None:
print("ERROR: INVENTREE_STATIC_ROOT directory not defined")
sys.exit(1)
# The filesystem location for served static files
MEDIA_ROOT = os.path.abspath(
get_setting(
'INVENTREE_MEDIA_ROOT',
CONFIG.get('media_root', None)
)
)
if MEDIA_ROOT is None:
print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
sys.exit(1)
# List of allowed hosts (default = allow all) # List of allowed hosts (default = allow all)
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*']) ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
@ -189,22 +213,12 @@ if cors_opt:
# Web URL endpoint for served static files # Web URL endpoint for served static files
STATIC_URL = '/static/' STATIC_URL = '/static/'
# The filesystem location for served static files STATICFILES_DIRS = []
STATIC_ROOT = os.path.abspath(
get_setting(
'INVENTREE_STATIC_ROOT',
CONFIG.get('static_root', '/home/inventree/data/static')
)
)
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'InvenTree', 'static'),
]
# Translated Template settings # Translated Template settings
STATICFILES_I18_PREFIX = 'i18n' STATICFILES_I18_PREFIX = 'i18n'
STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js', 'translated') STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js', 'translated')
STATICFILES_I18_TRG = STATICFILES_DIRS[0] + '_' + STATICFILES_I18_PREFIX STATICFILES_I18_TRG = os.path.join(BASE_DIR, 'InvenTree', 'static_i18n')
STATICFILES_DIRS.append(STATICFILES_I18_TRG) STATICFILES_DIRS.append(STATICFILES_I18_TRG)
STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX) STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX)
@ -218,19 +232,11 @@ STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes')
# Web URL endpoint for served media files # Web URL endpoint for served media files
MEDIA_URL = '/media/' MEDIA_URL = '/media/'
# The filesystem location for served static files
MEDIA_ROOT = os.path.abspath(
get_setting(
'INVENTREE_MEDIA_ROOT',
CONFIG.get('media_root', '/home/inventree/data/media')
)
)
if DEBUG: if DEBUG:
logger.info("InvenTree running in DEBUG mode") logger.info("InvenTree running in DEBUG mode")
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'") logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'") logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
# Application definition # Application definition
@ -320,6 +326,7 @@ TEMPLATES = [
'django.template.context_processors.i18n', 'django.template.context_processors.i18n',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
# Custom InvenTree context processors
'InvenTree.context.health_status', 'InvenTree.context.health_status',
'InvenTree.context.status_codes', 'InvenTree.context.status_codes',
'InvenTree.context.user_roles', 'InvenTree.context.user_roles',
@ -413,7 +420,7 @@ Configure the database backend based on the user-specified values.
- The following code lets the user "mix and match" database configuration - The following code lets the user "mix and match" database configuration
""" """
logger.info("Configuring database backend:") logger.debug("Configuring database backend:")
# Extract database configuration from the config.yaml file # Extract database configuration from the config.yaml file
db_config = CONFIG.get('database', {}) db_config = CONFIG.get('database', {})
@ -467,11 +474,9 @@ if db_engine in ['sqlite3', 'postgresql', 'mysql']:
db_name = db_config['NAME'] db_name = db_config['NAME']
db_host = db_config.get('HOST', "''") db_host = db_config.get('HOST', "''")
print("InvenTree Database Configuration") logger.info(f"DB_ENGINE: {db_engine}")
print("================================") logger.info(f"DB_NAME: {db_name}")
print(f"ENGINE: {db_engine}") logger.info(f"DB_HOST: {db_host}")
print(f"NAME: {db_name}")
print(f"HOST: {db_host}")
DATABASES['default'] = db_config DATABASES['default'] = db_config

View File

@ -640,6 +640,11 @@
z-index: 9999; z-index: 9999;
} }
.modal-error {
border: 2px #FCC solid;
background-color: #f5f0f0;
}
.modal-header { .modal-header {
border-bottom: 1px solid #ddd; border-bottom: 1px solid #ddd;
} }
@ -730,6 +735,13 @@
padding: 10px; padding: 10px;
} }
.form-panel {
border-radius: 5px;
border: 1px solid #ccc;
padding: 5px;
}
.modal input { .modal input {
width: 100%; width: 100%;
} }
@ -1037,6 +1049,11 @@ a.anchor {
height: 30px; height: 30px;
} }
/* Force minimum width of number input fields to show at least ~5 digits */
input[type='number']{
min-width: 80px;
}
.search-menu { .search-menu {
padding-top: 2rem; padding-top: 2rem;
} }
@ -1044,3 +1061,7 @@ a.anchor {
.search-menu .ui-menu-item { .search-menu .ui-menu-item {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.product-card-panel{
height: 100%;
}

View File

@ -36,7 +36,7 @@ def schedule_task(taskname, **kwargs):
# If this task is already scheduled, don't schedule it again # If this task is already scheduled, don't schedule it again
# Instead, update the scheduling parameters # Instead, update the scheduling parameters
if Schedule.objects.filter(func=taskname).exists(): if Schedule.objects.filter(func=taskname).exists():
logger.info(f"Scheduled task '{taskname}' already exists - updating!") logger.debug(f"Scheduled task '{taskname}' already exists - updating!")
Schedule.objects.filter(func=taskname).update(**kwargs) Schedule.objects.filter(func=taskname).update(**kwargs)
else: else:
@ -204,6 +204,25 @@ def check_for_updates():
) )
def delete_expired_sessions():
"""
Remove any expired user sessions from the database
"""
try:
from django.contrib.sessions.models import Session
# Delete any sessions that expired more than a day ago
expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1))
if True or expired.count() > 0:
logger.info(f"Deleting {expired.count()} expired sessions.")
expired.delete()
except AppRegistryNotReady:
logger.info("Could not perform 'delete_expired_sessions' - App registry not ready")
def update_exchange_rates(): def update_exchange_rates():
""" """
Update currency exchange rates Update currency exchange rates

View File

@ -111,6 +111,7 @@ translated_javascript_urls = [
url(r'^company.js', DynamicJsView.as_view(template_name='js/translated/company.js'), name='company.js'), url(r'^company.js', DynamicJsView.as_view(template_name='js/translated/company.js'), name='company.js'),
url(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'), url(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'),
url(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'), url(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'),
url(r'^helpers.js', DynamicJsView.as_view(template_name='js/translated/helpers.js'), name='helpers.js'),
url(r'^label.js', DynamicJsView.as_view(template_name='js/translated/label.js'), name='label.js'), url(r'^label.js', DynamicJsView.as_view(template_name='js/translated/label.js'), name='label.js'),
url(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), name='model_renderers.js'), url(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), name='model_renderers.js'),
url(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'), url(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),

View File

@ -8,13 +8,25 @@ import re
import common.models import common.models
INVENTREE_SW_VERSION = "0.4.5" INVENTREE_SW_VERSION = "0.5.0"
INVENTREE_API_VERSION = 9 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 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
v10 -> 2021-08-23
- Adds "purchase_price_currency" to StockItem serializer
- Adds "purchase_price_string" to StockItem serializer
- Purchase price is now writable for StockItem serializer
v9 -> 2021-08-09 v9 -> 2021-08-09
- Adds "price_string" to part pricing serializers - Adds "price_string" to part pricing serializers
@ -58,7 +70,7 @@ def inventreeInstanceTitle():
def inventreeVersion(): def inventreeVersion():
""" Returns the InvenTree version string """ """ Returns the InvenTree version string """
return INVENTREE_SW_VERSION return INVENTREE_SW_VERSION.lower().strip()
def inventreeVersionTuple(version=None): def inventreeVersionTuple(version=None):
@ -72,6 +84,30 @@ def inventreeVersionTuple(version=None):
return [int(g) for g in match.groups()] return [int(g) for g in match.groups()]
def isInvenTreeDevelopmentVersion():
"""
Return True if current InvenTree version is a "development" version
"""
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(): def isInvenTreeUpToDate():
""" """
Test if the InvenTree instance is "up to date" with the latest version. Test if the InvenTree instance is "up to date" with the latest version.

View File

@ -10,7 +10,8 @@ from django.db.models import BooleanField
from rest_framework import serializers from rest_framework import serializers
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializerField, UserSerializerBrief from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
from stock.serializers import StockItemSerializerBrief from stock.serializers import StockItemSerializerBrief
from stock.serializers import LocationSerializer from stock.serializers import LocationSerializer
@ -158,7 +159,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
] ]
class BuildAttachmentSerializer(InvenTreeModelSerializer): class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
""" """
Serializer for a BuildAttachment Serializer for a BuildAttachment
""" """
@ -172,6 +173,7 @@ class BuildAttachmentSerializer(InvenTreeModelSerializer):
'pk', 'pk',
'build', 'build',
'attachment', 'attachment',
'filename',
'comment', 'comment',
'upload_date', 'upload_date',
] ]

View File

@ -6,7 +6,7 @@
{{ block.super }} {{ block.super }}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
<b>{% trans "Automatically Allocate Stock" %}</b><br> <strong>{% trans "Automatically Allocate Stock" %}</strong><br>
{% trans "The following stock items will be allocated to the specified build output" %} {% trans "The following stock items will be allocated to the specified build output" %}
</div> </div>
{% if allocations %} {% if allocations %}
@ -24,7 +24,7 @@
</td> </td>
<td> <td>
{{ item.stock_item.part.full_name }}<br> {{ item.stock_item.part.full_name }}<br>
<i>{{ item.stock_item.part.description }}</i> <em>{{ item.stock_item.part.description }}</em>
</td> </td>
<td>{% decimal item.quantity %}</td> <td>{% decimal item.quantity %}</td>
<td>{{ item.stock_item.location }}</td> <td>{{ item.stock_item.location }}</td>

View File

@ -9,7 +9,7 @@
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
<b>{% trans "Build Order is incomplete" %}</b><br> <strong>{% trans "Build Order is incomplete" %}</strong><br>
<ul> <ul>
{% if build.incomplete_count > 0 %} {% if build.incomplete_count > 0 %}
<li>{% trans "Incompleted build outputs remain" %}</li> <li>{% trans "Incompleted build outputs remain" %}</li>

View File

@ -8,7 +8,7 @@
</p> </p>
{% if output %} {% if output %}
<p> <p>
{% blocktrans %}The allocated stock will be installed into the following build output:<br><i>{{output}}</i>{% endblocktrans %} {% blocktrans %}The allocated stock will be installed into the following build output:<br><em>{{output}}</em>{% endblocktrans %}
</p> </p>
{% endif %} {% endif %}
</div> </div>

View File

@ -40,7 +40,7 @@
{% if build.take_from %} {% if build.take_from %}
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>{% include "clip.html"%} <a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>{% include "clip.html"%}
{% else %} {% else %}
<i>{% trans "Stock can be taken from any available location." %}</i> <em>{% trans "Stock can be taken from any available location." %}</em>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -53,7 +53,7 @@
{{ build.destination }} {{ build.destination }}
</a>{% include "clip.html"%} </a>{% include "clip.html"%}
{% else %} {% else %}
<i>{% trans "Destination location not specified" %}</i> <em>{% trans "Destination location not specified" %}</em>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -127,7 +127,7 @@
{{ build.target_date }}{% if build.is_overdue %} <span class='fas fa-calendar-times icon-red'></span>{% endif %} {{ build.target_date }}{% if build.is_overdue %} <span class='fas fa-calendar-times icon-red'></span>{% endif %}
</td> </td>
{% else %} {% else %}
<td><i>{% trans "No target date set" %}</i></td> <td><em>{% trans "No target date set" %}</em></td>
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>
@ -136,7 +136,7 @@
{% if build.completion_date %} {% if build.completion_date %}
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td> <td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td>
{% else %} {% else %}
<td><i>{% trans "Build not complete" %}</i></td> <td><em>{% trans "Build not complete" %}</em></td>
{% endif %} {% endif %}
</tr> </tr>
</table> </table>
@ -222,7 +222,7 @@
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
<b>{% trans "Create a new build output" %}</b><br> <strong>{% trans "Create a new build output" %}</strong><br>
{% trans "No incomplete build outputs remain." %}<br> {% trans "No incomplete build outputs remain." %}<br>
{% trans "Create a new build output using the button above" %} {% trans "Create a new build output using the button above" %}
</div> </div>
@ -369,6 +369,7 @@ loadAttachmentTable(
constructForm(url, { constructForm(url, {
fields: { fields: {
filename: {},
comment: {}, comment: {},
}, },
onSuccess: reloadAttachmentTable, onSuccess: reloadAttachmentTable,

View File

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

View File

@ -20,13 +20,17 @@ from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.html import format_html
from django.core.validators import MinValueValidator, URLValidator from django.core.validators import MinValueValidator, URLValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
import InvenTree.helpers import InvenTree.helpers
import InvenTree.fields import InvenTree.fields
import logging
logger = logging.getLogger('inventree')
class BaseInvenTreeSetting(models.Model): class BaseInvenTreeSetting(models.Model):
""" """
@ -49,55 +53,37 @@ class BaseInvenTreeSetting(models.Model):
are assigned their default values are assigned their default values
""" """
keys = set()
settings = []
results = cls.objects.all() results = cls.objects.all()
if user is not None: if user is not None:
results = results.filter(user=user) results = results.filter(user=user)
# Query the database # Query the database
settings = {}
for setting in results: for setting in results:
if setting.key: if setting.key:
settings.append({ settings[setting.key.upper()] = setting.value
"key": setting.key.upper(),
"value": setting.value
})
keys.add(setting.key.upper())
# Specify any "default" values which are not in the database # Specify any "default" values which are not in the database
for key in cls.GLOBAL_SETTINGS.keys(): for key in cls.GLOBAL_SETTINGS.keys():
if key.upper() not in keys: if key.upper() not in settings:
settings.append({ settings[key.upper()] = cls.get_setting_default(key)
"key": key.upper(),
"value": cls.get_setting_default(key)
})
# Enforce javascript formatting
for idx, setting in enumerate(settings):
key = setting['key']
value = setting['value']
for key, value in settings.items():
validator = cls.get_setting_validator(key) validator = cls.get_setting_validator(key)
# Convert to javascript compatible booleans
if cls.validator_is_bool(validator): if cls.validator_is_bool(validator):
value = str(value).lower() value = InvenTree.helpers.str2bool(value)
# Numerical values remain the same
elif cls.validator_is_int(validator): elif cls.validator_is_int(validator):
pass try:
value = int(value)
except ValueError:
value = cls.get_setting_default(key)
# Wrap strings with quotes settings[key] = value
else:
value = format_html("'{}'", value)
setting["value"] = value
return settings return settings
@ -802,6 +788,44 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'description': _('Prefix value for purchase order reference'), 'description': _('Prefix value for purchase order reference'),
'default': 'PO', 'default': 'PO',
}, },
# enable/diable ui elements
'BUILD_FUNCTION_ENABLE': {
'name': _('Enable build'),
'description': _('Enable build functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
'BUY_FUNCTION_ENABLE': {
'name': _('Enable buy'),
'description': _('Enable buy functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
'SELL_FUNCTION_ENABLE': {
'name': _('Enable sell'),
'description': _('Enable sell functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
'STOCK_FUNCTION_ENABLE': {
'name': _('Enable stock'),
'description': _('Enable stock functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
'SO_FUNCTION_ENABLE': {
'name': _('Enable SO'),
'description': _('Enable SO functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
'PO_FUNCTION_ENABLE': {
'name': _('Enable PO'),
'description': _('Enable PO functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
} }
class Meta: class Meta:
@ -1021,7 +1045,7 @@ class PriceBreak(models.Model):
try: try:
converted = convert_money(self.price, currency_code) converted = convert_money(self.price, currency_code)
except MissingRate: except MissingRate:
print(f"WARNING: No currency conversion rate available for {self.price_currency} -> {currency_code}") logger.warning(f"No currency conversion rate available for {self.price_currency} -> {currency_code}")
return self.price.amount return self.price.amount
return converted.amount return converted.amount

View File

@ -6,9 +6,9 @@
{{ block.super }} {{ block.super }}
<!-- <!--
<p> <p>
<b>{{ name }}</b><br> <strong>{{ name }}</strong><br>
{{ description }}<br> {{ description }}<br>
<i>{% trans "Current value" %}: {{ value }}</i> <em>{% trans "Current value" %}: {{ value }}</em>
</p> </p>
--> -->
{% endblock %} {% endblock %}

View File

@ -501,6 +501,34 @@ class SupplierPart(models.Model):
'manufacturer_part': _("Linked manufacturer part must reference the same base part"), 'manufacturer_part': _("Linked manufacturer part must reference the same base part"),
}) })
def save(self, *args, **kwargs):
""" Overriding save method to connect an existing ManufacturerPart """
manufacturer_part = None
if all(key in kwargs for key in ('manufacturer', 'MPN')):
manufacturer_name = kwargs.pop('manufacturer')
MPN = kwargs.pop('MPN')
# Retrieve manufacturer part
try:
manufacturer_part = ManufacturerPart.objects.get(manufacturer__name=manufacturer_name, MPN=MPN)
except (ValueError, Company.DoesNotExist):
# ManufacturerPart does not exist
pass
if manufacturer_part:
if not self.manufacturer_part:
# Connect ManufacturerPart to SupplierPart
self.manufacturer_part = manufacturer_part
else:
raise ValidationError(f'SupplierPart {self.__str__} is already linked to {self.manufacturer_part}')
self.clean()
self.validate_unique()
super().save(*args, **kwargs)
part = models.ForeignKey('part.Part', on_delete=models.CASCADE, part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='supplier_parts', related_name='supplier_parts',
verbose_name=_('Base Part'), verbose_name=_('Base Part'),

View File

@ -204,9 +204,9 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True)) supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True))
manufacturer = serializers.PrimaryKeyRelatedField(source='manufacturer_part.manufacturer', read_only=True) manufacturer = serializers.CharField(read_only=True)
MPN = serializers.StringRelatedField(source='manufacturer_part.MPN') MPN = serializers.CharField(read_only=True)
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True) manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True)
@ -231,6 +231,25 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'supplier_detail', 'supplier_detail',
] ]
def create(self, validated_data):
""" Extract manufacturer data and process ManufacturerPart """
# Create SupplierPart
supplier_part = super().create(validated_data)
# Get ManufacturerPart raw data (unvalidated)
manufacturer = self.initial_data.get('manufacturer', None)
MPN = self.initial_data.get('MPN', None)
if manufacturer and MPN:
kwargs = {
'manufacturer': manufacturer,
'MPN': MPN,
}
supplier_part.save(**kwargs)
return supplier_part
class SupplierPriceBreakSerializer(InvenTreeModelSerializer): class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
""" Serializer for SupplierPriceBreak object """ """ Serializer for SupplierPriceBreak object """

View File

@ -78,7 +78,7 @@
{% if company.currency %} {% if company.currency %}
{{ company.currency }} {{ company.currency }}
{% else %} {% else %}
<i>{% trans "Uses default currency" %}</i> <em>{% trans "Uses default currency" %}</em>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -24,21 +24,19 @@
</button> </button>
{% endif %} {% endif %}
<div class='btn-group'> <div class='btn-group'>
<div class="dropdown" style="float: right;"> <button class="btn btn-primary dropdown-toggle" id='supplier-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span> <span class="caret"></span>
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li> <li><a href='#' id='multi-supplier-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %} {% endif %}
{% if roles.purchase_order.delete %} {% if roles.purchase_order.delete %}
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li> <li><a href='#' id='multi-supplier-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
</div> </div>
</div>
<div class='filter-list' id='filter-list-supplier-part'> <div class='filter-list' id='filter-list-supplier-part'>
<!-- Empty div (will be filled out with available BOM filters) --> <!-- Empty div (will be filled out with available BOM filters) -->
</div> </div>
@ -59,35 +57,33 @@
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
<div id='manufacturer-part-button-toolbar'> <div id='manufacturer-part-button-toolbar'>
<div class='button-toolbar container-fluid'> <div class='button-toolbar container-fluid'>
<div class='btn-group role='group'> <div class='btn-group' role='group'>
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<button class="btn btn-success" id='manufacturer-part-create' title='{% trans "Create new manufacturer part" %}'> <button type="button" class="btn btn-success" id='manufacturer-part-create' title='{% trans "Create new manufacturer part" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %} <span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
</button> </button>
{% endif %} {% endif %}
<div class='btn-group'> <div class='btn-group' role='group'>
<div class="dropdown" style="float: right;"> <button class="btn btn-primary dropdown-toggle" id='manufacturer-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
<button class="btn btn-primary dropdown-toggle" id='table-options', type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span> <span class="caret"></span>
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li> <li><a href='#' id='multi-manufacturer-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %} {% endif %}
{% if roles.purchase_order.delete %} {% if roles.purchase_order.delete %}
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li> <li><a href='#' id='multi-manufacturer-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
</div> </div>
</div>
<div class='filter-list' id='filter-list-supplier-part'> <div class='filter-list' id='filter-list-supplier-part'>
<!-- Empty div (will be filled out with available BOM filters) --> <!-- Empty div (will be filled out with available BOM filters) -->
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<table class='table table-striped table-condensed' id='part-table' data-toolbar='#manufacturer-part-button-toolbar'> <table class='table table-striped table-condensed' id='manufacturer-part-table' data-toolbar='#manufacturer-part-button-toolbar'>
</table> </table>
</div> </div>
</div> </div>
@ -109,7 +105,7 @@
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<div id='po-button-bar'> <div id='po-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='company-order2' title='{% trans "Create new purchase order" %}'> <button class='btn btn-success' type='button' id='company-order2' title='{% trans "Create new purchase order" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}</button> <span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}</button>
<div class='filter-list' id='filter-list-purchaseorder'> <div class='filter-list' id='filter-list-purchaseorder'>
<!-- Empty div --> <!-- Empty div -->
@ -131,7 +127,7 @@
{% if roles.sales_order.add %} {% if roles.sales_order.add %}
<div id='so-button-bar'> <div id='so-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='new-sales-order' title='{% trans "Create new sales order" %}'> <button class='btn btn-success' type='button' id='new-sales-order' title='{% trans "Create new sales order" %}'>
<div class='fas fa-plus-circle'></div> {% trans "New Sales Order" %} <div class='fas fa-plus-circle'></div> {% trans "New Sales Order" %}
</button> </button>
<div class='filter-list' id='filter-list-salesorder'> <div class='filter-list' id='filter-list-salesorder'>
@ -274,6 +270,10 @@
{% if company.is_manufacturer %} {% if company.is_manufacturer %}
function reloadManufacturerPartTable() {
$('#manufacturer-part-table').bootstrapTable('refresh');
}
$("#manufacturer-part-create").click(function () { $("#manufacturer-part-create").click(function () {
createManufacturerPart({ createManufacturerPart({
@ -285,7 +285,7 @@
}); });
loadManufacturerPartTable( loadManufacturerPartTable(
"#part-table", "#manufacturer-part-table",
"{% url 'api-manufacturer-part-list' %}", "{% url 'api-manufacturer-part-list' %}",
{ {
params: { params: {
@ -296,20 +296,20 @@
} }
); );
linkButtonsToSelection($("#manufacturer-table"), ['#table-options']); linkButtonsToSelection($("#manufacturer-part-table"), ['#manufacturer-table-options']);
$("#multi-part-delete").click(function() { $("#multi-manufacturer-part-delete").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections"); var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
deleteManufacturerParts(selections, { deleteManufacturerParts(selections, {
onSuccess: function() { onSuccess: function() {
$("#part-table").bootstrapTable("refresh"); $("#manufacturer-part-table").bootstrapTable("refresh");
} }
}); });
}); });
$("#multi-part-order").click(function() { $("#multi-manufacturer-part-order").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections"); var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
var parts = []; var parts = [];
@ -353,9 +353,9 @@
} }
); );
{% endif %} linkButtonsToSelection($("#supplier-part-table"), ['#supplier-table-options']);
$("#multi-part-delete").click(function() { $("#multi-supplier-part-delete").click(function() {
var selections = $("#supplier-part-table").bootstrapTable("getSelections"); var selections = $("#supplier-part-table").bootstrapTable("getSelections");
var requests = []; var requests = [];
@ -379,8 +379,8 @@
); );
}); });
$("#multi-part-order").click(function() { $("#multi-supplier-part-order").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections"); var selections = $("#supplier-part-table").bootstrapTable("getSelections");
var parts = []; var parts = [];
@ -395,6 +395,8 @@
}); });
}); });
{% endif %}
attachNavCallbacks({ attachNavCallbacks({
name: 'company', name: 'company',
default: 'company-stock' default: 'company-stock'

View File

@ -225,7 +225,7 @@ $("#multi-parameter-delete").click(function() {
<ul>`; <ul>`;
selections.forEach(function(item) { selections.forEach(function(item) {
text += `<li>${item.name} - <i>${item.value}</i></li>`; text += `<li>${item.name} - <em>${item.value}</em></li>`;
}); });
text += ` text += `

View File

@ -2,6 +2,10 @@
{% load static %} {% load static %}
{% load inventree_extras %} {% load inventree_extras %}
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
{% settings_value 'SO_FUNCTION_ENABLE' as enable_so %}
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
<ul class='list-group'> <ul class='list-group'>
<li class='list-group-item'> <li class='list-group-item'>
<a href='#' id='company-menu-toggle'> <a href='#' id='company-menu-toggle'>
@ -28,6 +32,7 @@
{% endif %} {% endif %}
{% if company.is_manufacturer or company.is_supplier %} {% if company.is_manufacturer or company.is_supplier %}
{% if enable_stock %}
<li class='list-group-item' title='{% trans "Stock Items" %}'> <li class='list-group-item' title='{% trans "Stock Items" %}'>
<a href='#' id='select-company-stock' class='nav-toggle'> <a href='#' id='select-company-stock' class='nav-toggle'>
<span class='fas fa-boxes sidebar-icon'></span> <span class='fas fa-boxes sidebar-icon'></span>
@ -35,8 +40,9 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% endif %}
{% if company.is_supplier %} {% if company.is_supplier and enable_po %}
<li class='list-group-item' title='{% trans "Purchase Orders" %}'> <li class='list-group-item' title='{% trans "Purchase Orders" %}'>
<a href='#' id='select-purchase-orders' class='nav-toggle'> <a href='#' id='select-purchase-orders' class='nav-toggle'>
<span class='fas fa-shopping-cart sidebar-icon'></span> <span class='fas fa-shopping-cart sidebar-icon'></span>
@ -45,7 +51,7 @@
</li> </li>
{% endif %} {% endif %}
{% if company.is_customer %} {% if company.is_customer and enable_so %}
<li class='list-group-item' title='{% trans "Sales Orders" %}'> <li class='list-group-item' title='{% trans "Sales Orders" %}'>
<a href='#' id='select-sales-orders' class='nav-toggle'> <a href='#' id='select-sales-orders' class='nav-toggle'>
<span class='fas fa-truck sidebar-icon'></span> <span class='fas fa-truck sidebar-icon'></span>

View File

@ -160,7 +160,7 @@ src="{% static 'img/blank_image.png' %}"
<div class='panel-content'> <div class='panel-content'>
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<div id='price-break-toolbar' class='btn-group'> <div id='price-break-toolbar' class='btn-group'>
<button class='btn btn-primary' id='new-price-break' type='button'> <button class='btn btn-success' id='new-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Price Break" %} <span class='fas fa-plus-circle'></span> {% trans "Add Price Break" %}
</button> </button>
</div> </div>

View File

@ -1,87 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="150mm"
height="150mm"
viewBox="0 0 531.49607 531.49606"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="folder_closed.svg"
inkscape:export-filename="/home/oliver/InvenTree/InvenTree/static/img/folder_closed.png"
inkscape:export-xdpi="50.799999"
inkscape:export-ydpi="50.799999">
<defs
id="defs4">
<inkscape:path-effect
effect="spiro"
id="path-effect4155"
is_visible="true" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.7"
inkscape:cx="215.08651"
inkscape:cy="510.45947"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1855"
inkscape:window-height="1056"
inkscape:window-x="65"
inkscape:window-y="24"
inkscape:window-maximized="1" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-520.86618)">
<g
id="g4205"
transform="translate(-77.288975,54.966576)"
style="stroke-width:25.00000298;stroke-miterlimit:4;stroke-dasharray:none"
inkscape:export-filename="/home/oliver/InvenTree/InvenTree/static/img/folder_closed.png"
inkscape:export-xdpi="50.799999"
inkscape:export-ydpi="50.799999">
<path
inkscape:connector-curvature="0"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffa000;fill-opacity:1;fill-rule:evenodd;stroke:#666666;stroke-width:25.00000298;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 176.8333,550.27626 c -28.32955,0 -51.13642,22.80687 -51.13642,51.13641 l 0,115.24942 258.96134,-60.54711 -97.67285,-105.83872 -110.15207,0 z"
id="path4177" />
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffca28;fill-opacity:1;fill-rule:evenodd;stroke:#666666;stroke-width:25.00000298;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4173"
width="434.67987"
height="310.33328"
x="125.69733"
y="602.68573"
rx="28.773136"
ry="29.555552" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -1,85 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="150mm"
height="150mm"
viewBox="0 0 531.49607 531.49606"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="folder_open.svg"
inkscape:export-filename="/home/oliver/InvenTree/InvenTree/static/img/folder_open.png"
inkscape:export-xdpi="50.800003"
inkscape:export-ydpi="50.800003">
<defs
id="defs4">
<inkscape:path-effect
effect="spiro"
id="path-effect4155"
is_visible="true" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.7"
inkscape:cx="462.22937"
inkscape:cy="510.45947"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1855"
inkscape:window-height="1056"
inkscape:window-x="65"
inkscape:window-y="24"
inkscape:window-maximized="1" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-520.86618)">
<g
id="g4228"
transform="translate(-248.03745,629.43489)"
style="stroke-width:25.00000298;stroke-miterlimit:4;stroke-dasharray:none">
<path
inkscape:connector-curvature="0"
id="rect4147"
d="m 328.26189,-24.722434 c -28.32955,0 -51.13642,22.8068796 -51.13642,51.13642 l 0,30.82854 0,84.420874 0,166.80171 c 0,16.37378 12.83411,29.5556 28.77442,29.5556 l 377.13316,0 c 15.94032,0 28.77161,-13.18182 28.77161,-29.5556 l 0,-251.222584 c 0,-16.37378 -12.83129,-29.5556 -28.77161,-29.5556 l -196.25334,0 -48.36575,-52.40936 -110.15207,0 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffa000;fill-opacity:1;fill-rule:evenodd;stroke:#666666;stroke-width:25.00000298;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<rect
transform="matrix(1,0,-0.16547222,0.98621445,0,0)"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffca28;fill-opacity:1;fill-rule:evenodd;stroke:#666666;stroke-width:25.17412473;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4149"
width="434.84564"
height="247.10779"
x="335.08032"
y="96.763504"
rx="28.784107"
ry="23.534075" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

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,13 +5,18 @@ JSON API for the Order app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include from django.conf.urls import url, include
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as rest_filters
from rest_framework import generics from rest_framework import generics
from rest_framework import filters, status from rest_framework import filters, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.api import AttachmentMixin from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
@ -27,6 +32,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
from .models import SalesOrderAttachment from .models import SalesOrderAttachment
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
from .serializers import SalesOrderAllocationSerializer from .serializers import SalesOrderAllocationSerializer
from .serializers import POReceiveSerializer
class POList(generics.ListCreateAPIView): class POList(generics.ListCreateAPIView):
@ -144,7 +150,7 @@ class POList(generics.ListCreateAPIView):
return queryset return queryset
filter_backends = [ filter_backends = [
DjangoFilterBackend, rest_filters.DjangoFilterBackend,
filters.SearchFilter, filters.SearchFilter,
filters.OrderingFilter, filters.OrderingFilter,
] ]
@ -204,6 +210,111 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView):
return queryset 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): class POLineItemList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of POLineItem objects """ API endpoint for accessing a list of POLineItem objects
@ -214,6 +325,14 @@ class POLineItemList(generics.ListCreateAPIView):
queryset = PurchaseOrderLineItem.objects.all() queryset = PurchaseOrderLineItem.objects.all()
serializer_class = POLineItemSerializer serializer_class = POLineItemSerializer
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = POLineItemSerializer.annotate_queryset(queryset)
return queryset
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
try: try:
@ -226,18 +345,26 @@ class POLineItemList(generics.ListCreateAPIView):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
filter_backends = [ filter_backends = [
DjangoFilterBackend, rest_filters.DjangoFilterBackend,
filters.SearchFilter, filters.SearchFilter,
filters.OrderingFilter InvenTreeOrderingFilter
] ]
ordering_field_aliases = {
'MPN': 'part__manufacturer_part__MPN',
'SKU': 'part__SKU',
'part_name': 'part__part__name',
}
ordering_fields = [ ordering_fields = [
'part__part__name', 'MPN',
'part__MPN', 'part_name',
'part__SKU', 'purchase_price',
'reference',
'quantity', 'quantity',
'received', 'received',
'reference',
'SKU',
'total_price',
] ]
search_fields = [ search_fields = [
@ -262,6 +389,14 @@ class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = PurchaseOrderLineItem.objects.all() queryset = PurchaseOrderLineItem.objects.all()
serializer_class = POLineItemSerializer serializer_class = POLineItemSerializer
def get_queryset(self):
queryset = super().get_queryset()
queryset = POLineItemSerializer.annotate_queryset(queryset)
return queryset
class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin): class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
""" """
@ -272,7 +407,7 @@ class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
serializer_class = SOAttachmentSerializer serializer_class = SOAttachmentSerializer
filter_backends = [ filter_backends = [
DjangoFilterBackend, rest_filters.DjangoFilterBackend,
] ]
filter_fields = [ filter_fields = [
@ -396,7 +531,7 @@ class SOList(generics.ListCreateAPIView):
return queryset return queryset
filter_backends = [ filter_backends = [
DjangoFilterBackend, rest_filters.DjangoFilterBackend,
filters.SearchFilter, filters.SearchFilter,
filters.OrderingFilter, filters.OrderingFilter,
] ]
@ -495,7 +630,7 @@ class SOLineItemList(generics.ListCreateAPIView):
return queryset return queryset
filter_backends = [ filter_backends = [
DjangoFilterBackend, rest_filters.DjangoFilterBackend,
filters.SearchFilter, filters.SearchFilter,
filters.OrderingFilter filters.OrderingFilter
] ]
@ -580,7 +715,7 @@ class SOAllocationList(generics.ListCreateAPIView):
return queryset return queryset
filter_backends = [ filter_backends = [
DjangoFilterBackend, rest_filters.DjangoFilterBackend,
] ]
# Default filterable fields # Default filterable fields
@ -598,7 +733,7 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
serializer_class = POAttachmentSerializer serializer_class = POAttachmentSerializer
filter_backends = [ filter_backends = [
DjangoFilterBackend, rest_filters.DjangoFilterBackend,
] ]
filter_fields = [ filter_fields = [
@ -616,13 +751,25 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
order_api_urls = [ order_api_urls = [
# API endpoints for purchase orders # API endpoints for purchase orders
url(r'po/attachment/', include([ 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'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'),
url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'), url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'),
])), ])),
url(r'^po/(?P<pk>\d+)/$', PODetail.as_view(), name='api-po-detail'),
url(r'^po/.*$', POList.as_view(), name='api-po-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'),
])),
# API endpoints for purchase order line items # API endpoints for purchase order line items
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.4 on 2021-08-12 17:49
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0040_alter_company_currency'),
('order', '0048_auto_20210702_2321'),
]
operations = [
migrations.AlterUniqueTogether(
name='purchaseorderlineitem',
unique_together={('order', 'part', 'quantity', 'purchase_price')},
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 3.2.4 on 2021-09-02 00:42
from django.db import migrations
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('stock', '0065_auto_20210701_0509'),
('order', '0049_alter_purchaseorderlineitem_unique_together'),
]
operations = [
migrations.AlterField(
model_name='purchaseorderlineitem',
name='destination',
field=mptt.fields.TreeForeignKey(blank=True, help_text='Where does the Purchaser want this item to be stored?', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='po_lines', to='stock.stocklocation', verbose_name='Destination'),
),
]

View File

@ -411,6 +411,11 @@ class PurchaseOrder(Order):
""" """
notes = kwargs.get('notes', '') notes = kwargs.get('notes', '')
barcode = kwargs.get('barcode', '')
# Prevent null values for barcode
if barcode is None:
barcode = ''
if not self.status == PurchaseOrderStatus.PLACED: if not self.status == PurchaseOrderStatus.PLACED:
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")}) raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
@ -433,7 +438,8 @@ class PurchaseOrder(Order):
quantity=quantity, quantity=quantity,
purchase_order=self, purchase_order=self,
status=status, status=status,
purchase_price=purchase_price, purchase_price=line.purchase_price,
uid=barcode
) )
stock.save(add_note=False) stock.save(add_note=False)
@ -729,7 +735,7 @@ class PurchaseOrderLineItem(OrderLineItem):
class Meta: class Meta:
unique_together = ( unique_together = (
('order', 'part') ('order', 'part', 'quantity', 'purchase_price')
) )
def __str__(self): def __str__(self):
@ -767,7 +773,13 @@ class PurchaseOrderLineItem(OrderLineItem):
help_text=_("Supplier part"), help_text=_("Supplier part"),
) )
received = models.DecimalField(decimal_places=5, max_digits=15, default=0, verbose_name=_('Received'), help_text=_('Number of items received')) received = models.DecimalField(
decimal_places=5,
max_digits=15,
default=0,
verbose_name=_('Received'),
help_text=_('Number of items received')
)
purchase_price = InvenTreeModelMoneyField( purchase_price = InvenTreeModelMoneyField(
max_digits=19, max_digits=19,
@ -778,7 +790,7 @@ class PurchaseOrderLineItem(OrderLineItem):
) )
destination = TreeForeignKey( destination = TreeForeignKey(
'stock.StockLocation', on_delete=models.DO_NOTHING, 'stock.StockLocation', on_delete=models.SET_NULL,
verbose_name=_('Destination'), verbose_name=_('Destination'),
related_name='po_lines', related_name='po_lines',
blank=True, null=True, blank=True, null=True,

View File

@ -7,18 +7,27 @@ from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.db import models
from django.db.models import Case, When, Value from django.db.models import Case, When, Value
from django.db.models import BooleanField from django.db.models import BooleanField, ExpressionWrapper, F
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import InvenTreeMoneySerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField from InvenTree.serializers import InvenTreeAttachmentSerializerField
from InvenTree.status_codes import StockStatus
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
import stock.models
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrder, PurchaseOrderLineItem
@ -108,6 +117,23 @@ class POSerializer(InvenTreeModelSerializer):
class POLineItemSerializer(InvenTreeModelSerializer): class POLineItemSerializer(InvenTreeModelSerializer):
@staticmethod
def annotate_queryset(queryset):
"""
Add some extra annotations to this queryset:
- Total price = purchase_price * quantity
"""
queryset = queryset.annotate(
total_price=ExpressionWrapper(
F('purchase_price') * F('quantity'),
output_field=models.DecimalField()
)
)
return queryset
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False) part_detail = kwargs.pop('part_detail', False)
@ -118,10 +144,11 @@ class POLineItemSerializer(InvenTreeModelSerializer):
self.fields.pop('part_detail') self.fields.pop('part_detail')
self.fields.pop('supplier_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) quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0) received = serializers.FloatField(default=0)
total_price = serializers.FloatField(read_only=True)
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True) supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
@ -157,10 +184,136 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'purchase_price_string', 'purchase_price_string',
'destination', 'destination',
'destination_detail', 'destination_detail',
'total_price',
] ]
class POAttachmentSerializer(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 Serializers for the PurchaseOrderAttachment model
""" """
@ -174,6 +327,7 @@ class POAttachmentSerializer(InvenTreeModelSerializer):
'pk', 'pk',
'order', 'order',
'attachment', 'attachment',
'filename',
'comment', 'comment',
'upload_date', 'upload_date',
] ]
@ -381,7 +535,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
] ]
class SOAttachmentSerializer(InvenTreeModelSerializer): class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
""" """
Serializers for the SalesOrderAttachment model Serializers for the SalesOrderAttachment model
""" """
@ -395,6 +549,7 @@ class SOAttachmentSerializer(InvenTreeModelSerializer):
'pk', 'pk',
'order', 'order',
'attachment', 'attachment',
'filename',
'comment', 'comment',
'upload_date', 'upload_date',
] ]

View File

@ -57,7 +57,7 @@
{% for duplicate in duplicates %} {% for duplicate in duplicates %}
{% if duplicate == col.value %} {% if duplicate == col.value %}
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'> <div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
<b>{% trans "Duplicate selection" %}</b> <strong>{% trans "Duplicate selection" %}</strong>
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@ -115,7 +115,7 @@
{{ block.super }} {{ block.super }}
$('.bomselect').select2({ $('.bomselect').select2({
dropdownAutoWidth: true, width: '100%',
matcher: partialMatcher, matcher: partialMatcher,
}); });

View File

@ -38,7 +38,7 @@
<tr id='part_row_{{ part.id }}'> <tr id='part_row_{{ part.id }}'>
<td> <td>
{% include "hover_image.html" with image=part.image hover=False %} {% include "hover_image.html" with image=part.image hover=False %}
{{ part.full_name }} <small><i>{{ part.description }}</i></small> {{ part.full_name }} <small><em>{{ part.description }}</em></small>
</td> </td>
<td> <td>
<button class='btn btn-default btn-create' onClick='newSupplierPartFromOrderWizard()' id='new_supplier_part_{{ part.id }}' part='{{ part.pk }}' title='{% trans "Create new supplier part" %}' type='button'> <button class='btn btn-default btn-create' onClick='newSupplierPartFromOrderWizard()' id='new_supplier_part_{{ part.id }}' part='{{ part.pk }}' title='{% trans "Create new supplier part" %}' type='button'>
@ -62,7 +62,7 @@
</select> </select>
</div> </div>
{% if not part.order_supplier %} {% if not part.order_supplier %}
<span class='help-inline'>{% blocktrans with name=part.name %}Select a supplier for <i>{{name}}</i>{% endblocktrans %}</span> <span class='help-inline'>{% blocktrans with name=part.name %}Select a supplier for <em>{{name}}</em>{% endblocktrans %}</span>
{% endif %} {% endif %}
</div> </div>
</td> </td>

View File

@ -19,7 +19,7 @@
<div class='panel-content'> <div class='panel-content'>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'> <div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %} {% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
<button type='button' class='btn btn-primary' id='new-po-line'> <button type='button' class='btn btn-success' id='new-po-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %} <span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button> </button>
<a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'> <a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'>
@ -28,7 +28,7 @@
{% endif %} {% endif %}
</div> </div>
<table class='table table-striped table-condensed' id='po-table' data-toolbar='#order-toolbar-buttons'> <table class='table table-striped table-condensed' id='po-line-table' data-toolbar='#order-toolbar-buttons'>
</table> </table>
</div> </div>
</div> </div>
@ -38,7 +38,7 @@
<h4>{% trans "Received Items" %}</h4> <h4>{% trans "Received Items" %}</h4>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% include "stock_table.html" with read_only=True %} {% include "stock_table.html" with prevent_new_stock=True %}
</div> </div>
</div> </div>
@ -122,6 +122,7 @@
constructForm(url, { constructForm(url, {
fields: { fields: {
filename: {},
comment: {}, comment: {},
}, },
onSuccess: reloadAttachmentTable, onSuccess: reloadAttachmentTable,
@ -200,250 +201,27 @@ $('#new-po-line').click(function() {
}, },
method: 'POST', method: 'POST',
title: '{% trans "Add Line Item" %}', title: '{% trans "Add Line Item" %}',
onSuccess: reloadTable, onSuccess: function() {
$('#po-line-table').bootstrapTable('refresh');
},
}); });
}); });
{% endif %} {% endif %}
function reloadTable() { loadPurchaseOrderLineItemTable('#po-line-table', {
$("#po-table").bootstrapTable("refresh"); order: {{ order.pk }},
}
function setupCallbacks() {
// Setup callbacks for the line buttons
var table = $("#po-table");
{% 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 }}, supplier: {{ order.supplier.pk }},
} {% if order.status == PurchaseOrderStatus.PENDING %}
}, allow_edit: true,
quantity: {}, {% else %}
reference: {}, allow_edit: false,
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,
});
});
{% endif %} {% 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-table").inventreeTable({
onPostBody: setupCallbacks,
name: 'purchaseorder',
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__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: 'part__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: 'part__MPN',
field: 'supplier_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;
}
},
{
sortable: true,
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: true,
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 order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
if (row.received < row.quantity) { allow_receive: true,
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}'); {% else %}
} allow_receive: false,
{% endif %} {% endif %}
html += `</div>`;
return html;
},
}
]
}); });
attachNavCallbacks({ attachNavCallbacks({

View File

@ -17,7 +17,7 @@
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'> <div class='btn-group'>
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'> <button class='btn btn-success' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %} <span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}
</button> </button>
{% endif %} {% endif %}

View File

@ -5,7 +5,7 @@
{% block form %} {% block form %}
{% blocktrans with desc=order.description %}Receive outstanding parts for <b>{{order}}</b> - <i>{{desc}}</i>{% endblocktrans %} {% blocktrans with desc=order.description %}Receive outstanding parts for <strong>{{order}}</strong> - <em>{{desc}}</em>{% endblocktrans %}
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'> <form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
{% csrf_token %} {% csrf_token %}

View File

@ -112,6 +112,7 @@
constructForm(url, { constructForm(url, {
fields: { fields: {
filename: {},
comment: {}, comment: {},
}, },
onSuccess: reloadAttachmentTable, onSuccess: reloadAttachmentTable,

View File

@ -22,7 +22,7 @@
{% endif %} {% endif %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
<b>{% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }}</b> <strong>{% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }}</strong>
<br> <br>
{% trans "Shipping this order means that the order will no longer be editable." %} {% trans "Shipping this order means that the order will no longer be editable." %}
</div> </div>

View File

@ -17,7 +17,7 @@
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'> <div class='btn-group'>
{% if roles.sales_order.add %} {% if roles.sales_order.add %}
<button class='btn btn-primary' type='button' id='so-create' title='{% trans "Create new sales order" %}'> <button class='btn btn-success' type='button' id='so-create' title='{% trans "Create new sales order" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Sales Order" %} <span class='fas fa-plus-circle'></span> {% trans "New Sales Order" %}
</button> </button>
{% endif %} {% endif %}

View File

@ -6,9 +6,9 @@
<div class='alert alert-block alert-warning'> <div class='alert alert-block alert-warning'>
{% trans "This action will unallocate the following stock from the Sales Order" %}: {% trans "This action will unallocate the following stock from the Sales Order" %}:
<br> <br>
<b> <strong>
{% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }} {% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }}
{% if allocation.item.location %} ({{ allocation.get_location }}){% endif %} {% if allocation.item.location %} ({{ allocation.get_location }}){% endif %}
</b> </strong>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -9,8 +9,11 @@ from rest_framework import status
from django.urls import reverse from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase 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): class OrderTest(InvenTreeAPITestCase):
@ -201,6 +204,250 @@ class PurchaseOrderTest(OrderTest):
response = self.get(url, expected_code=404) 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): class SalesOrderTest(OrderTest):
""" """
Tests for the SalesOrder API Tests for the SalesOrder API

View File

@ -9,12 +9,14 @@ from django.conf.urls import url, include
from django.urls import reverse from django.urls import reverse
from django.http import JsonResponse from django.http import JsonResponse
from django.db.models import Q, F, Count, Min, Max, Avg from django.db.models import Q, F, Count, Min, Max, Avg
from django.db import transaction
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import filters, serializers from rest_framework import filters, serializers
from rest_framework import generics from rest_framework import generics
from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
@ -23,7 +25,7 @@ from djmoney.money import Money
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal from decimal import Decimal, InvalidOperation
from .models import Part, PartCategory, BomItem from .models import Part, PartCategory, BomItem
from .models import PartParameter, PartParameterTemplate from .models import PartParameter, PartParameterTemplate
@ -31,7 +33,10 @@ from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from stock.models import StockItem from company.models import Company, ManufacturerPart, SupplierPart
from stock.models import StockItem, StockLocation
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from build.models import Build from build.models import Build
@ -630,6 +635,7 @@ class PartList(generics.ListCreateAPIView):
else: else:
return Response(data) return Response(data)
@transaction.atomic
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
""" """
We wish to save the user who created this part! We wish to save the user who created this part!
@ -637,6 +643,8 @@ class PartList(generics.ListCreateAPIView):
Note: Implementation copied from DRF class CreateModelMixin Note: Implementation copied from DRF class CreateModelMixin
""" """
# TODO: Unit tests for this function!
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@ -680,21 +688,97 @@ class PartList(generics.ListCreateAPIView):
pass pass
# Optionally create initial stock item # Optionally create initial stock item
try: initial_stock = str2bool(request.data.get('initial_stock', False))
initial_stock = Decimal(request.data.get('initial_stock', 0))
if initial_stock > 0 and part.default_location is not None: if initial_stock:
try:
initial_stock_quantity = Decimal(request.data.get('initial_stock_quantity', ''))
if initial_stock_quantity <= 0:
raise ValidationError({
'initial_stock_quantity': [_('Must be greater than zero')],
})
except (ValueError, InvalidOperation): # Invalid quantity provided
raise ValidationError({
'initial_stock_quantity': [_('Must be a valid quantity')],
})
initial_stock_location = request.data.get('initial_stock_location', None)
try:
initial_stock_location = StockLocation.objects.get(pk=initial_stock_location)
except (ValueError, StockLocation.DoesNotExist):
initial_stock_location = None
if initial_stock_location is None:
if part.default_location is not None:
initial_stock_location = part.default_location
else:
raise ValidationError({
'initial_stock_location': [_('Specify location for initial part stock')],
})
stock_item = StockItem( stock_item = StockItem(
part=part, part=part,
quantity=initial_stock, quantity=initial_stock_quantity,
location=part.default_location, location=initial_stock_location,
) )
stock_item.save(user=request.user) stock_item.save(user=request.user)
# Optionally add manufacturer / supplier data to the part
if part.purchaseable and str2bool(request.data.get('add_supplier_info', False)):
try:
manufacturer = Company.objects.get(pk=request.data.get('manufacturer', None))
except: except:
pass manufacturer = None
try:
supplier = Company.objects.get(pk=request.data.get('supplier', None))
except:
supplier = None
mpn = str(request.data.get('MPN', '')).strip()
sku = str(request.data.get('SKU', '')).strip()
# Construct a manufacturer part
if manufacturer or mpn:
if not manufacturer:
raise ValidationError({
'manufacturer': [_("This field is required")]
})
if not mpn:
raise ValidationError({
'MPN': [_("This field is required")]
})
manufacturer_part = ManufacturerPart.objects.create(
part=part,
manufacturer=manufacturer,
MPN=mpn
)
else:
# No manufacturer part data specified
manufacturer_part = None
if supplier or sku:
if not supplier:
raise ValidationError({
'supplier': [_("This field is required")]
})
if not sku:
raise ValidationError({
'SKU': [_("This field is required")]
})
SupplierPart.objects.create(
part=part,
supplier=supplier,
SKU=sku,
manufacturer_part=manufacturer_part,
)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)

View File

@ -35,6 +35,8 @@ from stdimage.models import StdImageField
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from datetime import datetime from datetime import datetime
import hashlib import hashlib
from djmoney.contrib.exchange.models import convert_money
from common.settings import currency_code_default
from InvenTree import helpers from InvenTree import helpers
from InvenTree import validators from InvenTree import validators
@ -1514,7 +1516,7 @@ class Part(MPTTModel):
return (min_price, max_price) return (min_price, max_price)
def get_bom_price_range(self, quantity=1, internal=False): def get_bom_price_range(self, quantity=1, internal=False, purchase=False):
""" Return the price range of the BOM for this part. """ Return the price range of the BOM for this part.
Adds the minimum price for all components in the BOM. Adds the minimum price for all components in the BOM.
@ -1531,7 +1533,7 @@ class Part(MPTTModel):
print("Warning: Item contains itself in BOM") print("Warning: Item contains itself in BOM")
continue continue
prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal) prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal, purchase=purchase)
if prices is None: if prices is None:
continue continue
@ -1555,16 +1557,17 @@ class Part(MPTTModel):
return (min_price, max_price) return (min_price, max_price)
def get_price_range(self, quantity=1, buy=True, bom=True, internal=False): def get_price_range(self, quantity=1, buy=True, bom=True, internal=False, purchase=False):
""" Return the price range for this part. This price can be either: """ Return the price range for this part. This price can be either:
- Supplier price (if purchased from suppliers) - Supplier price (if purchased from suppliers)
- BOM price (if built from other parts) - BOM price (if built from other parts)
- Internal price (if set for the part) - Internal price (if set for the part)
- Purchase price (if set for the part)
Returns: Returns:
Minimum of the supplier, BOM or internal price. If no pricing available, returns None Minimum of the supplier, BOM, internal or purchase price. If no pricing available, returns None
""" """
# only get internal price if set and should be used # only get internal price if set and should be used
@ -1572,6 +1575,12 @@ class Part(MPTTModel):
internal_price = self.get_internal_price(quantity) internal_price = self.get_internal_price(quantity)
return internal_price, internal_price return internal_price, internal_price
# only get purchase price if set and should be used
if purchase:
purchase_price = self.get_purchase_price(quantity)
if purchase_price:
return purchase_price
buy_price_range = self.get_supplier_price_range(quantity) if buy else None buy_price_range = self.get_supplier_price_range(quantity) if buy else None
bom_price_range = self.get_bom_price_range(quantity, internal=internal) if bom else None bom_price_range = self.get_bom_price_range(quantity, internal=internal) if bom else None
@ -1641,6 +1650,13 @@ class Part(MPTTModel):
def internal_unit_pricing(self): def internal_unit_pricing(self):
return self.get_internal_price(1) return self.get_internal_price(1)
def get_purchase_price(self, quantity):
currency = currency_code_default()
prices = [convert_money(item.purchase_price, currency).amount for item in self.stock_items.all() if item.purchase_price]
if prices:
return min(prices) * quantity, max(prices) * quantity
return None
@transaction.atomic @transaction.atomic
def copy_bom_from(self, other, clear=True, **kwargs): def copy_bom_from(self, other, clear=True, **kwargs):
""" """

View File

@ -1,6 +1,7 @@
""" """
JSON serializers for Part app JSON serializers for Part app
""" """
import imghdr import imghdr
from decimal import Decimal from decimal import Decimal
@ -16,7 +17,9 @@ from djmoney.contrib.django_rest_framework import MoneyField
from InvenTree.serializers import (InvenTreeAttachmentSerializerField, from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField, InvenTreeImageSerializerField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeAttachmentSerializer,
InvenTreeMoneySerializer) InvenTreeMoneySerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem from stock.models import StockItem
@ -51,7 +54,7 @@ class CategorySerializer(InvenTreeModelSerializer):
] ]
class PartAttachmentSerializer(InvenTreeModelSerializer): class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
""" """
Serializer for the PartAttachment class Serializer for the PartAttachment class
""" """
@ -65,6 +68,7 @@ class PartAttachmentSerializer(InvenTreeModelSerializer):
'pk', 'pk',
'part', 'part',
'attachment', 'attachment',
'filename',
'comment', 'comment',
'upload_date', 'upload_date',
] ]
@ -202,6 +206,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
'stock', 'stock',
'trackable', 'trackable',
'virtual', 'virtual',
'units',
] ]

View File

@ -11,13 +11,13 @@
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
{% else %} {% else %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
{% blocktrans with part=part.full_name %}The BOM for <i>{{ part }}</i> has changed, and must be validated.<br>{% endblocktrans %} {% blocktrans with part=part.full_name %}The BOM for <em>{{ part }}</em> has changed, and must be validated.<br>{% endblocktrans %}
{% endif %} {% endif %}
{% blocktrans with part=part.full_name checker=part.bom_checked_by check_date=part.bom_checked_date %}The BOM for <i>{{ part }}</i> was last checked by {{ checker }} on {{ check_date }}{% endblocktrans %} {% blocktrans with part=part.full_name checker=part.bom_checked_by check_date=part.bom_checked_date %}The BOM for <em>{{ part }}</em> was last checked by {{ checker }} on {{ check_date }}{% endblocktrans %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-danger alert-block'> <div class='alert alert-danger alert-block'>
<b>{% blocktrans with part=part.full_name %}The BOM for <i>{{ part }}</i> has not been validated.{% endblocktrans %}</b> <strong>{% blocktrans with part=part.full_name %}The BOM for <em>{{ part }}</em> has not been validated.{% endblocktrans %}</strong>
</div> </div>
{% endif %} {% endif %}

View File

@ -9,7 +9,7 @@
{% if part.has_bom %} {% if part.has_bom %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
<b>{% trans "Warning" %}</b><br> <strong>{% trans "Warning" %}</strong><br>
{% trans "This part already has a Bill of Materials" %}<br> {% trans "This part already has a Bill of Materials" %}<br>
</div> </div>
{% endif %} {% endif %}

View File

@ -57,7 +57,7 @@
{% for duplicate in duplicates %} {% for duplicate in duplicates %}
{% if duplicate == col.value %} {% if duplicate == col.value %}
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'> <div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
<b>{% trans "Duplicate selection" %}</b> <strong>{% trans "Duplicate selection" %}</strong>
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@ -43,9 +43,9 @@
{% block form_alert %} {% block form_alert %}
<div class='alert alert-info alert-block'> <div class='alert alert-info alert-block'>
<b>{% trans "Requirements for BOM upload" %}:</b> <strong>{% trans "Requirements for BOM upload" %}:</strong>
<ul> <ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <b><a href="/part/bom_template/">{% trans "BOM Upload Template" %}</a></b></li> <li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href="/part/bom_template/">{% trans "BOM Upload Template" %}</a></strong></li>
<li>{% trans "Each part must already exist in the database" %}</li> <li>{% trans "Each part must already exist in the database" %}</li>
</ul> </ul>
</div> </div>

View File

@ -3,7 +3,7 @@
{% load i18n %} {% load i18n %}
{% block pre_form_content %} {% block pre_form_content %}
{% blocktrans with part.full_name as part %}Confirm that the Bill of Materials (BOM) is valid for:<br><i>{{ part }}</i>{% endblocktrans %} {% blocktrans with part.full_name as part %}Confirm that the Bill of Materials (BOM) is valid for:<br><em>{{ part }}</em>{% endblocktrans %}
<div class='alert alert-warning alert-block'> <div class='alert alert-warning alert-block'>
{% trans 'This will validate each line in the BOM.' %} {% trans 'This will validate each line in the BOM.' %}

View File

@ -138,6 +138,7 @@
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li> <li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
{% endif %} {% endif %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li> <li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
<li><a href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>{% trans "Print Labels" %}</a></li>
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li> <li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
</ul> </ul>
</div> </div>
@ -276,6 +277,7 @@
constructForm('{% url "api-part-list" %}', { constructForm('{% url "api-part-list" %}', {
method: 'POST', method: 'POST',
fields: fields, fields: fields,
groups: partGroups(),
title: '{% trans "Create Part" %}', title: '{% trans "Create Part" %}',
onSuccess: function(data) { onSuccess: function(data) {
// Follow the new part // Follow the new part

View File

@ -8,13 +8,13 @@
{% if matches %} {% if matches %}
<div class='alert alert-block alert-warning'> <div class='alert alert-block alert-warning'>
<b>{% trans "Possible Matching Parts" %}</b> <strong>{% trans "Possible Matching Parts" %}</strong>
<p>{% trans "The new part may be a duplicate of these existing parts" %}:</p> <p>{% trans "The new part may be a duplicate of these existing parts" %}:</p>
<ul class='list-group'> <ul class='list-group'>
{% for match in matches %} {% for match in matches %}
<li class='list-group-item list-group-item-condensed'> <li class='list-group-item list-group-item-condensed'>
{% decimal match.ratio as match_per %} {% decimal match.ratio as match_per %}
{% blocktrans with full_name=match.part.full_name desc=match.part.description %}{{full_name}} - <i>{{desc}}</i> ({{match_per}}% match){% endblocktrans %} {% blocktrans with full_name=match.part.full_name desc=match.part.description %}{{full_name}} - <em>{{desc}}</em> ({{match_per}}% match){% endblocktrans %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -18,7 +18,7 @@
<div class='panel-content'> <div class='panel-content'>
{% if part.is_template %} {% if part.is_template %}
<div class='alert alert-info alert-block'> <div class='alert alert-info alert-block'>
{% blocktrans with full_name=part.full_name%}Showing stock for all variants of <i>{{full_name}}</i>{% endblocktrans %} {% blocktrans with full_name=part.full_name%}Showing stock for all variants of <em>{{full_name}}</em>{% endblocktrans %}
</div> </div>
{% endif %} {% endif %}
{% include "stock_table.html" %} {% include "stock_table.html" %}
@ -74,7 +74,7 @@
<div id='so-button-bar'> <div id='so-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
{% if 0 %} {% if 0 %}
<button class='btn btn-primary' type='button' id='part-order2' title='{% trans "New sales order" %}'>{% trans "New Order" %}</button> <button class='btn btn-success' type='button' id='part-order2' title='{% trans "New sales order" %}'>{% trans "New Order" %}</button>
{% endif %} {% endif %}
<div class='filter-list' id='filter-list-salesorder'> <div class='filter-list' id='filter-list-salesorder'>
<!-- An empty div in which the filter list will be constructed --> <!-- An empty div in which the filter list will be constructed -->
@ -185,7 +185,7 @@
<div id='related-button-bar'> <div id='related-button-bar'>
<div class='button-toolbar container-fluid' style='float: left;'> <div class='button-toolbar container-fluid' style='float: left;'>
{% if roles.part.change %} {% if roles.part.change %}
<button class='btn btn-primary' type='button' id='add-related-part' title='{% trans "Add Related" %}'>{% trans "Add Related" %}</button> <button class='btn btn-success' type='button' id='add-related-part' title='{% trans "Add Related" %}'>{% trans "Add Related" %}</button>
<div class='filter-list' id='filter-list-related'> <div class='filter-list' id='filter-list-related'>
<!-- An empty div in which the filter list will be constructed --> <!-- An empty div in which the filter list will be constructed -->
</div> </div>
@ -667,6 +667,8 @@
}); });
onPanelLoad("test-templates", function() { onPanelLoad("test-templates", function() {
// Load test template table
loadPartTestTemplateTable( loadPartTestTemplateTable(
$("#test-template-table"), $("#test-template-table"),
{ {
@ -677,12 +679,9 @@
} }
); );
// Callback for "add test template" button
$("#add-test-template").click(function() { $("#add-test-template").click(function() {
function reloadTestTemplateTable() {
$("#test-template-table").bootstrapTable("refresh");
}
constructForm('{% url "api-part-test-template-list" %}', { constructForm('{% url "api-part-test-template-list" %}', {
method: 'POST', method: 'POST',
fields: { fields: {
@ -697,39 +696,10 @@
} }
}, },
title: '{% trans "Add Test Result Template" %}', 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,
});
});
}); });
}); });
@ -868,6 +838,7 @@
constructForm(url, { constructForm(url, {
fields: { fields: {
filename: {},
comment: {}, comment: {},
}, },
title: '{% trans "Edit Attachment" %}', title: '{% trans "Edit Attachment" %}',

View File

@ -50,7 +50,7 @@
{% for duplicate in duplicates %} {% for duplicate in duplicates %}
{% if duplicate == col.value %} {% if duplicate == col.value %}
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'> <div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
<b>{% trans "Duplicate selection" %}</b> <strong>{% trans "Duplicate selection" %}</strong>
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@ -57,7 +57,7 @@
{% for duplicate in duplicates %} {% for duplicate in duplicates %}
{% if duplicate == col.value %} {% if duplicate == col.value %}
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'> <div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
<b>{% trans "Duplicate selection" %}</b> <strong>{% trans "Duplicate selection" %}</strong>
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@ -1,8 +1,24 @@
{% extends "base.html" %} {% extends "part/part_app_base.html" %}
{% load inventree_extras %} {% load inventree_extras %}
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% block menubar %}
<ul class='list-group'>
<li class='list-group-item'>
<a href='#' id='part-menu-toggle'>
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
</a>
</li>
<li class='list-group-item' title='{% trans "Return To Parts" %}'>
<a href='{% url "part-index" %}' id='select-upload-file' class='nav-toggle'>
<span class='fas fa-undo side-icon'></span>
{% trans "Return To Parts" %}
</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class='panel panel-default panel-inventree'> <div class='panel panel-default panel-inventree'>
<div class='panel-heading'> <div class='panel-heading'>
@ -54,4 +70,9 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
enableNavbar({
label: 'part',
toggleId: '#part-menu-toggle',
});
{% endblock %} {% endblock %}

View File

@ -4,6 +4,12 @@
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} {% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% settings_value 'PART_SHOW_RELATED' as show_related %} {% settings_value 'PART_SHOW_RELATED' as show_related %}
{% settings_value 'BUILD_FUNCTION_ENABLE' as enable_build %}
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
{% settings_value 'SO_FUNCTION_ENABLE' as enable_so %}
{% settings_value 'BUY_FUNCTION_ENABLE' as enable_buy %}
{% settings_value 'SELL_FUNCTION_ENABLE' as enable_sell %}
<ul class='list-group'> <ul class='list-group'>
<li class='list-group-item'> <li class='list-group-item'>
@ -25,12 +31,14 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if enable_stock %}
<li class='list-group-item' title='{% trans "Stock Items" %}'> <li class='list-group-item' title='{% trans "Stock Items" %}'>
<a href='#' id='select-part-stock' class='nav-toggle'> <a href='#' id='select-part-stock' class='nav-toggle'>
<span class='menu-tab-icon fas fa-boxes sidebar-icon'></span> <span class='menu-tab-icon fas fa-boxes sidebar-icon'></span>
{% trans "Stock" %} {% trans "Stock" %}
</a> </a>
</li> </li>
{% endif %}
{% if part.assembly %} {% if part.assembly %}
<li class='list-group-item' title='{% trans "Bill of Materials" %}'> <li class='list-group-item' title='{% trans "Bill of Materials" %}'>
<a href='#' id='select-bom' class='nav-toggle'> <a href='#' id='select-bom' class='nav-toggle'>
@ -38,7 +46,7 @@
{% trans "Bill of Materials" %} {% trans "Bill of Materials" %}
</a> </a>
</li> </li>
{% if roles.build.view %} {% if roles.build.view and enable_build %}
<li class='list-group-item ' title='{% trans "Build Orders" %}'> <li class='list-group-item ' title='{% trans "Build Orders" %}'>
<a href='#' id='select-build-orders' class='nav-toggle'> <a href='#' id='select-build-orders' class='nav-toggle'>
<span class='menu-tab-icon fas fa-tools sidebar-icon'></span> <span class='menu-tab-icon fas fa-tools sidebar-icon'></span>
@ -55,19 +63,22 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if enable_buy or enable_sell %}
<li class='list-group-item' title='{% trans "Pricing Information" %}'> <li class='list-group-item' title='{% trans "Pricing Information" %}'>
<a href='#' id='select-pricing' class='nav-toggle'> <a href='#' id='select-pricing' class='nav-toggle'>
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span> <span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
{% trans "Prices" %} {% trans "Prices" %}
</a> </a>
</li> </li>
{% if part.purchaseable and roles.purchase_order.view %} {% endif %}
{% if part.purchaseable and roles.purchase_order.view and enable_buy %}
<li class='list-group-item' title='{% trans "Suppliers" %}'> <li class='list-group-item' title='{% trans "Suppliers" %}'>
<a href='#' id='select-suppliers' class='nav-toggle'> <a href='#' id='select-suppliers' class='nav-toggle'>
<span class='menu-tab-icon fas fa-building sidebar-icon'></span> <span class='menu-tab-icon fas fa-building sidebar-icon'></span>
{% trans "Suppliers" %} {% trans "Suppliers" %}
</a> </a>
</li> </li>
{% if enable_po %}
<li class='list-group-item' title='{% trans "Purchase Orders" %}'> <li class='list-group-item' title='{% trans "Purchase Orders" %}'>
<a href='#' id='select-purchase-orders' class='nav-toggle'> <a href='#' id='select-purchase-orders' class='nav-toggle'>
<span class='menu-tab-icon fas fa-shopping-cart sidebar-icon'></span> <span class='menu-tab-icon fas fa-shopping-cart sidebar-icon'></span>
@ -75,7 +86,8 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if roles.sales_order.view %} {% endif %}
{% if part.salable and roles.sales_order.view and enable_sell and enable_so %}
<li class='list-group-item' title='{% trans "Sales Orders" %}'> <li class='list-group-item' title='{% trans "Sales Orders" %}'>
<a href='#' id='select-sales-orders' class='nav-toggle'> <a href='#' id='select-sales-orders' class='nav-toggle'>
<span class='menu-tab-icon fas fa-truck sidebar-icon'></span> <span class='menu-tab-icon fas fa-truck sidebar-icon'></span>

View File

@ -10,6 +10,11 @@
{% block content %} {% block content %}
{% settings_value 'BUY_FUNCTION_ENABLE' as enable_buy %}
{% settings_value 'SELL_FUNCTION_ENABLE' as enable_sell %}
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
<div class="panel panel-default panel-inventree"> <div class="panel panel-default panel-inventree">
<!-- Default panel contents --> <!-- Default panel contents -->
<div class="panel-heading"><h3>{{ part.full_name }}</h3></div> <div class="panel-heading"><h3>{{ part.full_name }}</h3></div>
@ -80,10 +85,12 @@
</div> </div>
{% endif %} {% endif %}
{% if part.active %} {% if part.active %}
{% if enable_buy or enable_sell %}
<button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'> <button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'>
<span id='part-price-icon' class='fas fa-dollar-sign'/> <span id='part-price-icon' class='fas fa-dollar-sign'/>
</button> </button>
{% if roles.stock.change %} {% endif %}
{% if roles.stock.change and enable_stock %}
<div class='btn-group'> <div class='btn-group'>
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> <button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-boxes'></span> <span class='caret'></span> <span class='fas fa-boxes'></span> <span class='caret'></span>
@ -104,8 +111,8 @@
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
{% if part.purchaseable %} {% if part.purchaseable and roles.purchase_order.add %}
{% if roles.purchase_order.add %} {% if enable_buy and enable_po %}
<button type='button' class='btn btn-default' id='part-order' title='{% trans "Order part" %}'> <button type='button' class='btn btn-default' id='part-order' title='{% trans "Order part" %}'>
<span id='part-order-icon' class='fas fa-shopping-cart'/> <span id='part-order-icon' class='fas fa-shopping-cart'/>
</button> </button>
@ -123,7 +130,7 @@
{% if roles.part.change %} {% if roles.part.change %}
<li><a href='#' id='part-edit'><span class='fas fa-edit icon-blue'></span> {% trans "Edit part" %}</a></li> <li><a href='#' id='part-edit'><span class='fas fa-edit icon-blue'></span> {% trans "Edit part" %}</a></li>
{% endif %} {% endif %}
{% if not part.active and roles.part.delete %} {% if roles.part.delete %}
<li><a href='#' id='part-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete part" %}</a></li> <li><a href='#' id='part-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete part" %}</a></li>
{% endif %} {% endif %}
</ul> </ul>
@ -496,12 +503,13 @@
}); });
{% endif %} {% endif %}
{% if not part.active and roles.part.delete %} {% if roles.part.delete %}
$("#part-delete").click(function() { $("#part-delete").click(function() {
launchModalForm( launchModalForm(
"{% url 'part-delete' part.id %}", "{% url 'part-delete' part.id %}",
{ {
redirect: {% if part.category %}"{% url 'category-detail' part.category.id %}"{% else %}"{% url 'part-index' %}"{% endif %} redirect: {% if part.category %}"{% url 'category-detail' part.category.id %}"{% else %}"{% url 'part-index' %}"{% endif %},
no_post: {% if part.active %}true{% else %}false{% endif %},
} }
); );
}); });

View File

@ -9,11 +9,11 @@
<table class='table table-striped table-condensed table-price-two'> <table class='table table-striped table-condensed table-price-two'>
<tr> <tr>
<td><b>{% trans 'Part' %}</b></td> <td><strong>{% trans 'Part' %}</strong></td>
<td>{{ part }}</td> <td>{{ part }}</td>
</tr> </tr>
<tr> <tr>
<td><b>{% trans 'Quantity' %}</b></td> <td><strong>{% trans 'Quantity' %}</strong></td>
<td>{{ quantity }}</td> <td>{{ quantity }}</td>
</tr> </tr>
</table> </table>
@ -23,13 +23,13 @@
<table class='table table-striped table-condensed table-price-three'> <table class='table table-striped table-condensed table-price-three'>
{% if min_total_buy_price %} {% if min_total_buy_price %}
<tr> <tr>
<td><b>{% trans 'Unit Cost' %}</b></td> <td><strong>{% trans 'Unit Cost' %}</strong></td>
<td>Min: {% include "price.html" with price=min_unit_buy_price %}</td> <td>Min: {% include "price.html" with price=min_unit_buy_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_buy_price %}</td> <td>Max: {% include "price.html" with price=max_unit_buy_price %}</td>
</tr> </tr>
{% if quantity > 1 %} {% if quantity > 1 %}
<tr> <tr>
<td><b>{% trans 'Total Cost' %}</b></td> <td><strong>{% trans 'Total Cost' %}</strong></td>
<td>Min: {% include "price.html" with price=min_total_buy_price %}</td> <td>Min: {% include "price.html" with price=min_total_buy_price %}</td>
<td>Max: {% include "price.html" with price=max_total_buy_price %}</td> <td>Max: {% include "price.html" with price=max_total_buy_price %}</td>
</tr> </tr>
@ -37,7 +37,7 @@
{% else %} {% else %}
<tr> <tr>
<td colspan='3'> <td colspan='3'>
<span class='warning-msg'><i>{% trans 'No supplier pricing available' %}</i></span> <span class='warning-msg'><em>{% trans 'No supplier pricing available' %}</em></span>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
@ -49,28 +49,43 @@
<table class='table table-striped table-condensed table-price-three'> <table class='table table-striped table-condensed table-price-three'>
{% if min_total_bom_price %} {% if min_total_bom_price %}
<tr> <tr>
<td><b>{% trans 'Unit Cost' %}</b></td> <td><strong>{% trans 'Unit Cost' %}</strong></td>
<td>Min: {% include "price.html" with price=min_unit_bom_price %}</td> <td>Min: {% include "price.html" with price=min_unit_bom_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_bom_price %}</td> <td>Max: {% include "price.html" with price=max_unit_bom_price %}</td>
</tr> </tr>
{% if quantity > 1 %} {% if quantity > 1 %}
<tr> <tr>
<td><b>{% trans 'Total Cost' %}</b></td> <td><strong>{% trans 'Total Cost' %}</strong></td>
<td>Min: {% include "price.html" with price=min_total_bom_price %}</td> <td>Min: {% include "price.html" with price=min_total_bom_price %}</td>
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td> <td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if min_total_bom_purchase_price %}
<tr>
<td><strong>{% trans 'Unit Purchase Price' %}</strong></td>
<td>Min: {% include "price.html" with price=min_unit_bom_purchase_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_bom_purchase_price %}</td>
</tr>
{% if quantity > 1 %}
<tr>
<td><strong>{% trans 'Total Purchase Price' %}</strong></td>
<td>Min: {% include "price.html" with price=min_total_bom_purchase_price %}</td>
<td>Max: {% include "price.html" with price=max_total_bom_purchase_price %}</td>
</tr>
{% endif %}
{% endif %}
{% if part.has_complete_bom_pricing == False %} {% if part.has_complete_bom_pricing == False %}
<tr> <tr>
<td colspan='3'> <td colspan='3'>
<span class='warning-msg'><i>{% trans 'Note: BOM pricing is incomplete for this part' %}</i></span> <span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
{% else %} {% else %}
<tr> <tr>
<td colspan='3'> <td colspan='3'>
<span class='warning-msg'><i>{% trans 'No BOM pricing available' %}</i></span> <span class='warning-msg'><em>{% trans 'No BOM pricing available' %}</em></span>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
@ -82,11 +97,11 @@
<h4>{% trans 'Internal Price' %}</h4> <h4>{% trans 'Internal Price' %}</h4>
<table class='table table-striped table-condensed table-price-two'> <table class='table table-striped table-condensed table-price-two'>
<tr> <tr>
<td><b>{% trans 'Unit Cost' %}</b></td> <td><strong>{% trans 'Unit Cost' %}</strong></td>
<td>{% include "price.html" with price=unit_internal_part_price %}</td> <td>{% include "price.html" with price=unit_internal_part_price %}</td>
</tr> </tr>
<tr> <tr>
<td><b>{% trans 'Total Cost' %}</b></td> <td><strong>{% trans 'Total Cost' %}</strong></td>
<td>{% include "price.html" with price=total_internal_part_price %}</td> <td>{% include "price.html" with price=total_internal_part_price %}</td>
</tr> </tr>
</table> </table>
@ -97,11 +112,11 @@
<h4>{% trans 'Sale Price' %}</h4> <h4>{% trans 'Sale Price' %}</h4>
<table class='table table-striped table-condensed table-price-two'> <table class='table table-striped table-condensed table-price-two'>
<tr> <tr>
<td><b>{% trans 'Unit Cost' %}</b></td> <td><strong>{% trans 'Unit Cost' %}</strong></td>
<td>{% include "price.html" with price=unit_part_price %}</td> <td>{% include "price.html" with price=unit_part_price %}</td>
</tr> </tr>
<tr> <tr>
<td><b>{% trans 'Total Cost' %}</b></td> <td><strong>{% trans 'Total Cost' %}</strong></td>
<td>{% include "price.html" with price=total_part_price %}</td> <td>{% include "price.html" with price=total_part_price %}</td>
</tr> </tr>
</table> </table>

View File

@ -3,8 +3,18 @@
{% block pre_form_content %} {% block pre_form_content %}
{% if part.active %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
{% blocktrans with full_name=part.full_name %}Are you sure you want to delete part '<b>{{full_name}}</b>'?{% endblocktrans %} {% blocktrans with full_name=part.full_name %}Part '<strong>{{full_name}}</strong>' cannot be deleted as it is still marked as <strong>active</strong>.
<br>Disable the "Active" part attribute and re-try.
{% endblocktrans %}
</div>
{% else %}
<div class='alert alert-block alert-danger'>
{% blocktrans with full_name=part.full_name %}Are you sure you want to delete part '<strong>{{full_name}}</strong>'?{% endblocktrans %}
</div> </div>
{% if part.used_in_count %} {% if part.used_in_count %}
@ -55,4 +65,12 @@
<p>{% blocktrans with count=part.serials.all|length full_name=part.full_name %}There are {{count}} unique parts tracked for '{{full_name}}'. Deleting this part will permanently remove this tracking information.{% endblocktrans %}</p> <p>{% blocktrans with count=part.serials.all|length full_name=part.full_name %}There are {{count}} unique parts tracked for '{{full_name}}'. Deleting this part will permanently remove this tracking information.{% endblocktrans %}</p>
{% endif %} {% endif %}
{% endif %}
{% endblock %}
{% block form %}
{% if not part.active %}
{{ block.super }}
{% endif %}
{% endblock %} {% endblock %}

View File

@ -18,7 +18,7 @@
{% if part.supplier_count > 0 %} {% if part.supplier_count > 0 %}
{% if min_total_buy_price %} {% if min_total_buy_price %}
<tr> <tr>
<td><b>{% trans 'Supplier Pricing' %}</b> <td><strong>{% trans 'Supplier Pricing' %}</strong>
<a href="#supplier-cost" title='{% trans "Show supplier cost" %}'><span class="fas fa-search-dollar"></span></a> <a href="#supplier-cost" title='{% trans "Show supplier cost" %}'><span class="fas fa-search-dollar"></span></a>
<a href="#purchase-price" title='{% trans "Show purchase price" %}'><span class="fas fa-chart-bar"></span></a> <a href="#purchase-price" title='{% trans "Show purchase price" %}'><span class="fas fa-chart-bar"></span></a>
</td> </td>
@ -37,7 +37,7 @@
{% else %} {% else %}
<tr> <tr>
<td colspan='4'> <td colspan='4'>
<span class='warning-msg'><i>{% trans 'No supplier pricing available' %}</i></span> <span class='warning-msg'><em>{% trans 'No supplier pricing available' %}</em></span>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
@ -46,7 +46,7 @@
{% if part.bom_count > 0 %} {% if part.bom_count > 0 %}
{% if min_total_bom_price %} {% if min_total_bom_price %}
<tr> <tr>
<td><b>{% trans 'BOM Pricing' %}</b> <td><strong>{% trans 'BOM Pricing' %}</strong>
<a href="#bom-cost" title='{% trans "Show BOM cost" %}'><span class="fas fa-search-dollar"></span></a> <a href="#bom-cost" title='{% trans "Show BOM cost" %}'><span class="fas fa-search-dollar"></span></a>
</td> </td>
<td>{% trans 'Unit Cost' %}</td> <td>{% trans 'Unit Cost' %}</td>
@ -61,17 +61,36 @@
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td> <td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if min_total_bom_purchase_price %}
<tr>
<td></td>
<td>{% trans 'Unit Purchase Price' %}</td>
<td>Min: {% include "price.html" with price=min_unit_bom_purchase_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_bom_purchase_price %}</td>
</tr>
{% if quantity > 1 %}
<tr>
<td></td>
<td>{% trans 'Total Purchase Price' %}</td>
<td>Min: {% include "price.html" with price=min_total_bom_purchase_price %}</td>
<td>Max: {% include "price.html" with price=max_total_bom_purchase_price %}</td>
</tr>
{% endif %}
{% endif %}
{% if part.has_complete_bom_pricing == False %} {% if part.has_complete_bom_pricing == False %}
<tr> <tr>
<td colspan='4'> <td colspan='4'>
<span class='warning-msg'><i>{% trans 'Note: BOM pricing is incomplete for this part' %}</i></span> <span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
{% else %} {% else %}
<tr> <tr>
<td colspan='4'> <td colspan='4'>
<span class='warning-msg'><i>{% trans 'No BOM pricing available' %}</i></span> <span class='warning-msg'><em>{% trans 'No BOM pricing available' %}</em></span>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
@ -80,7 +99,7 @@
{% if show_internal_price and roles.sales_order.view %} {% if show_internal_price and roles.sales_order.view %}
{% if total_internal_part_price %} {% if total_internal_part_price %}
<tr> <tr>
<td><b>{% trans 'Internal Price' %}</b></td> <td><strong>{% trans 'Internal Price' %}</strong></td>
<td>{% trans 'Unit Cost' %}</td> <td>{% trans 'Unit Cost' %}</td>
<td colspan='2'>{% include "price.html" with price=unit_internal_part_price %}</td> <td colspan='2'>{% include "price.html" with price=unit_internal_part_price %}</td>
</tr> </tr>
@ -94,7 +113,7 @@
{% if total_part_price %} {% if total_part_price %}
<tr> <tr>
<td><b>{% trans 'Sale Price' %}</b> <td><strong>{% trans 'Sale Price' %}</strong>
<a href="#sale-cost" title='{% trans "Show sale cost" %}'><span class="fas fa-search-dollar"></span></a> <a href="#sale-cost" title='{% trans "Show sale cost" %}'><span class="fas fa-search-dollar"></span></a>
<a href="#sale-price" title='{% trans "Show sale price" %}'><span class="fas fa-chart-bar"></span></a> <a href="#sale-price" title='{% trans "Show sale price" %}'><span class="fas fa-chart-bar"></span></a>
</td> </td>
@ -160,8 +179,7 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<h4>{% trans 'Stock Pricing' %} <h4>{% trans 'Stock Pricing' %}
<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part. <em class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.&#10;The Supplier Unit Cost is the current purchase price for that supplier part."></em>
The Supplier Unit Cost is the current purchase price for that supplier part."></i>
</h4> </h4>
{% if price_history|length > 0 %} {% if price_history|length > 0 %}
<div style="max-width: 99%; min-height: 300px"> <div style="max-width: 99%; min-height: 300px">
@ -193,7 +211,7 @@
</div> </div>
<div class="col col-md-4"> <div class="col col-md-4">
<div id='internal-price-break-toolbar' class='btn-group'> <div id='internal-price-break-toolbar' class='btn-group'>
<button class='btn btn-primary' id='new-internal-price-break' type='button'> <button class='btn btn-success' id='new-internal-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %} <span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %}
</button> </button>
</div> </div>
@ -249,7 +267,7 @@
</div> </div>
<div class="col col-md-4"> <div class="col col-md-4">
<div id='price-break-toolbar' class='btn-group'> <div id='price-break-toolbar' class='btn-group'>
<button class='btn btn-primary' id='new-price-break' type='button'> <button class='btn btn-success' id='new-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Price Break" %} <span class='fas fa-plus-circle'></span> {% trans "Add Price Break" %}
</button> </button>
</div> </div>

View File

@ -6,8 +6,8 @@
{{ block.super }} {{ block.super }}
<div class='alert alert-info alert-block'> <div class='alert alert-info alert-block'>
<b>{% trans "Create new part variant" %}</b><br> <strong>{% trans "Create new part variant" %}</strong><br>
{% blocktrans with full_name=part.full_name %}Create a new variant of template <i>'{{full_name}}'</i>.{% endblocktrans %} {% blocktrans with full_name=part.full_name %}Create a new variant of template <em>'{{full_name}}'</em>.{% endblocktrans %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -6,6 +6,7 @@ over and above the built-in Django tags.
import os import os
import sys import sys
from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.conf import settings as djangosettings from django.conf import settings as djangosettings
@ -135,6 +136,21 @@ def inventree_version(*args, **kwargs):
return version.inventreeVersion() 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() @register.simple_tag()
def inventree_api_version(*args, **kwargs): def inventree_api_version(*args, **kwargs):
""" Return InvenTree API version """ """ Return InvenTree API version """
@ -168,7 +184,10 @@ def inventree_github_url(*args, **kwargs):
@register.simple_tag() @register.simple_tag()
def inventree_docs_url(*args, **kwargs): def inventree_docs_url(*args, **kwargs):
""" Return URL for InvenTree documenation site """ """ 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() @register.simple_tag()
@ -262,6 +281,26 @@ def get_available_themes(*args, **kwargs):
return themes return themes
@register.simple_tag()
def primitive_to_javascript(primitive):
"""
Convert a python primitive to a javascript primitive.
e.g. True -> true
'hello' -> '"hello"'
"""
if type(primitive) is bool:
return str(primitive).lower()
elif type(primitive) in [int, float]:
return primitive
else:
# Wrap with quotes
return format_html("'{}'", primitive)
@register.filter @register.filter
def keyvalue(dict, key): def keyvalue(dict, key):
""" """

View File

@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase):
'location', 'location',
'bom', 'bom',
'test_templates', 'test_templates',
'company',
] ]
roles = [ roles = [
@ -465,6 +466,149 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertFalse(response.data['active']) self.assertFalse(response.data['active'])
self.assertFalse(response.data['purchaseable']) self.assertFalse(response.data['purchaseable'])
def test_initial_stock(self):
"""
Tests for initial stock quantity creation
"""
url = reverse('api-part-list')
# Track how many parts exist at the start of this test
n = Part.objects.count()
# Set up required part data
data = {
'category': 1,
'name': "My lil' test part",
'description': 'A part with which to test',
}
# Signal that we want to add initial stock
data['initial_stock'] = True
# Post without a quantity
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_quantity', response.data)
# Post with an invalid quantity
data['initial_stock_quantity'] = "ax"
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_quantity', response.data)
# Post with a negative quantity
data['initial_stock_quantity'] = -1
response = self.post(url, data, expected_code=400)
self.assertIn('Must be greater than zero', response.data['initial_stock_quantity'])
# Post with a valid quantity
data['initial_stock_quantity'] = 12345
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_location', response.data)
# Check that the number of parts has not increased (due to form failures)
self.assertEqual(Part.objects.count(), n)
# Now, set a location
data['initial_stock_location'] = 1
response = self.post(url, data, expected_code=201)
# Check that the part has been created
self.assertEqual(Part.objects.count(), n + 1)
pk = response.data['pk']
new_part = Part.objects.get(pk=pk)
self.assertEqual(new_part.total_stock, 12345)
def test_initial_supplier_data(self):
"""
Tests for initial creation of supplier / manufacturer data
"""
url = reverse('api-part-list')
n = Part.objects.count()
# Set up initial part data
data = {
'category': 1,
'name': 'Buy Buy Buy',
'description': 'A purchaseable part',
'purchaseable': True,
}
# Signal that we wish to create initial supplier data
data['add_supplier_info'] = True
# Specify MPN but not manufacturer
data['MPN'] = 'MPN-123'
response = self.post(url, data, expected_code=400)
self.assertIn('manufacturer', response.data)
# Specify manufacturer but not MPN
del data['MPN']
data['manufacturer'] = 1
response = self.post(url, data, expected_code=400)
self.assertIn('MPN', response.data)
# Specify SKU but not supplier
del data['manufacturer']
data['SKU'] = 'SKU-123'
response = self.post(url, data, expected_code=400)
self.assertIn('supplier', response.data)
# Specify supplier but not SKU
del data['SKU']
data['supplier'] = 1
response = self.post(url, data, expected_code=400)
self.assertIn('SKU', response.data)
# Check that no new parts have been created
self.assertEqual(Part.objects.count(), n)
# Now, fully specify the details
data['SKU'] = 'SKU-123'
data['supplier'] = 3
data['MPN'] = 'MPN-123'
data['manufacturer'] = 6
response = self.post(url, data, expected_code=201)
self.assertEqual(Part.objects.count(), n + 1)
pk = response.data['pk']
new_part = Part.objects.get(pk=pk)
# Check that there is a new manufacturer part *and* a new supplier part
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): class PartDetailTests(InvenTreeAPITestCase):
""" """

Some files were not shown because too many files have changed in this diff Show More