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:
- name: Checkout Code
uses: actions/checkout@v2
- name: Check version number
run: |
python3 ci/check_version_number.py --dev
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx

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

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

View File

@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v2
- name: Check Release tag
run: |
python3 ci/check_version_number.py ${{ github.event.release.tag_name }}
python3 ci/check_version_number.py --release --tag ${{ github.event.release.tag_name }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
@ -32,5 +32,7 @@ jobs:
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
target: production
build-args:
tag: ${{ github.event.release.tag_name }}
repository: inventree/inventree
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:
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: 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: |
cd ci
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
dummy_image.*
_tmp.csv
# Sphinx files
docs/_build
@ -66,8 +67,16 @@ secret_key.txt
.coverage
htmlcov/
# Temporary javascript files (used for testing)
js_tmp/
# Development files
dev/
# Locale stats file
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.
## 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
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...")
# Remove successful task results from the database
InvenTree.tasks.schedule_task(
'InvenTree.tasks.delete_successful_tasks',
schedule_type=Schedule.DAILY,
)
# Check for InvenTree updates
InvenTree.tasks.schedule_task(
'InvenTree.tasks.check_for_updates',
schedule_type=Schedule.DAILY
)
# Heartbeat to let the server know the background worker is running
InvenTree.tasks.schedule_task(
'InvenTree.tasks.heartbeat',
schedule_type=Schedule.MINUTES,
minutes=15
)
# Keep exchange rates up to date
InvenTree.tasks.schedule_task(
'InvenTree.tasks.update_exchange_rates',
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):
"""
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(),
}
# The following keys are required to denote system health
health_keys = [
'django_q_running',
]
all_healthy = True
for k in status.keys():
for k in health_keys:
if status[k] is not True:
all_healthy = False

View File

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

View File

@ -5,8 +5,10 @@ Generic models which provide extra functionality over base Django model types.
from __future__ import unicode_literals
import os
import logging
from django.db import models
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
@ -21,6 +23,9 @@ from mptt.exceptions import InvalidMove
from .validators import validate_tree_name
logger = logging.getLogger('inventree')
def rename_attachment(instance, filename):
"""
Function for renaming an attachment file.
@ -77,6 +82,72 @@ class InvenTreeAttachment(models.Model):
def basename(self):
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:
abstract = True

View File

@ -10,6 +10,8 @@ import os
from decimal import Decimal
from collections import OrderedDict
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError as DjangoValidationError
@ -46,10 +48,12 @@ class InvenTreeMoneySerializer(MoneyField):
amount = None
try:
if amount is not None:
if amount is not None and amount is not empty:
amount = Decimal(amount)
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)
@ -93,8 +97,13 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
# If instance is None, we are creating a new instance
if instance is None and data is not empty:
# Required to side-step immutability of a QueryDict
data = data.copy()
if data is None:
data = OrderedDict()
else:
new_data = OrderedDict()
new_data.update(data)
data = new_data
# Add missing fields which have default values
ModelClass = self.Meta.model
@ -167,6 +176,18 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
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):
"""
Perform serializer validation.
@ -188,7 +209,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
# Update instance fields
for attr, value in data.items():
setattr(instance, attr, value)
try:
setattr(instance, attr, value)
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=serializers.as_serializer_error(exc))
# Run a 'full_clean' on the model.
# Note that by default, DRF does *not* perform full model validation!
@ -208,6 +232,22 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
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):
"""
Override the DRF native FileField serializer,

View File

@ -169,6 +169,30 @@ else:
logger.exception(f"Couldn't load keyfile {key_file}")
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)
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
@ -189,22 +213,12 @@ if cors_opt:
# Web URL endpoint for served static files
STATIC_URL = '/static/'
# The filesystem location for served static files
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'),
]
STATICFILES_DIRS = []
# Translated Template settings
STATICFILES_I18_PREFIX = 'i18n'
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_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
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:
logger.info("InvenTree running in DEBUG mode")
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
# Application definition
@ -320,6 +326,7 @@ TEMPLATES = [
'django.template.context_processors.i18n',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
# Custom InvenTree context processors
'InvenTree.context.health_status',
'InvenTree.context.status_codes',
'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
"""
logger.info("Configuring database backend:")
logger.debug("Configuring database backend:")
# Extract database configuration from the config.yaml file
db_config = CONFIG.get('database', {})
@ -467,11 +474,9 @@ if db_engine in ['sqlite3', 'postgresql', 'mysql']:
db_name = db_config['NAME']
db_host = db_config.get('HOST', "''")
print("InvenTree Database Configuration")
print("================================")
print(f"ENGINE: {db_engine}")
print(f"NAME: {db_name}")
print(f"HOST: {db_host}")
logger.info(f"DB_ENGINE: {db_engine}")
logger.info(f"DB_NAME: {db_name}")
logger.info(f"DB_HOST: {db_host}")
DATABASES['default'] = db_config

View File

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

View File

@ -8,36 +8,48 @@ import re
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
v9 -> 2021-08-09
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
- Adds "price_string" to part pricing serializers
v8 -> 2021-07-19
v8 -> 2021-07-19
- Refactors the API interface for SupplierPart and ManufacturerPart models
- ManufacturerPart objects can no longer be created via the SupplierPart API endpoint
v7 -> 2021-07-03
v7 -> 2021-07-03
- Introduced the concept of "API forms" in https://github.com/inventree/InvenTree/pull/1716
- API OPTIONS endpoints provide comprehensive field metedata
- Multiple new API endpoints added for database models
v6 -> 2021-06-23
v6 -> 2021-06-23
- Part and Company images can now be directly uploaded via the REST API
v5 -> 2021-06-21
v5 -> 2021-06-21
- Adds API interface for manufacturer part parameters
v4 -> 2021-06-01
v4 -> 2021-06-01
- BOM items can now accept "variant stock" to be assigned against them
- Many slight API tweaks were needed to get this to work properly!
v3 -> 2021-05-22:
v3 -> 2021-05-22:
- The updated StockItem "history tracking" now uses a different interface
"""
@ -58,7 +70,7 @@ def inventreeInstanceTitle():
def inventreeVersion():
""" Returns the InvenTree version string """
return INVENTREE_SW_VERSION
return INVENTREE_SW_VERSION.lower().strip()
def inventreeVersionTuple(version=None):
@ -72,6 +84,30 @@ def inventreeVersionTuple(version=None):
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():
"""
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 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 LocationSerializer
@ -158,7 +159,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
]
class BuildAttachmentSerializer(InvenTreeModelSerializer):
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
Serializer for a BuildAttachment
"""
@ -172,6 +173,7 @@ class BuildAttachmentSerializer(InvenTreeModelSerializer):
'pk',
'build',
'attachment',
'filename',
'comment',
'upload_date',
]

View File

@ -6,7 +6,7 @@
{{ block.super }}
<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" %}
</div>
{% if allocations %}
@ -24,7 +24,7 @@
</td>
<td>
{{ item.stock_item.part.full_name }}<br>
<i>{{ item.stock_item.part.description }}</i>
<em>{{ item.stock_item.part.description }}</em>
</td>
<td>{% decimal item.quantity %}</td>
<td>{{ item.stock_item.location }}</td>

View File

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

View File

@ -8,7 +8,7 @@
</p>
{% if output %}
<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>
{% endif %}
</div>

View File

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

View File

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

View File

@ -20,13 +20,17 @@ from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate
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.exceptions import ValidationError
import InvenTree.helpers
import InvenTree.fields
import logging
logger = logging.getLogger('inventree')
class BaseInvenTreeSetting(models.Model):
"""
@ -49,55 +53,37 @@ class BaseInvenTreeSetting(models.Model):
are assigned their default values
"""
keys = set()
settings = []
results = cls.objects.all()
if user is not None:
results = results.filter(user=user)
# Query the database
settings = {}
for setting in results:
if setting.key:
settings.append({
"key": setting.key.upper(),
"value": setting.value
})
keys.add(setting.key.upper())
settings[setting.key.upper()] = setting.value
# Specify any "default" values which are not in the database
for key in cls.GLOBAL_SETTINGS.keys():
if key.upper() not in keys:
if key.upper() not in settings:
settings.append({
"key": key.upper(),
"value": cls.get_setting_default(key)
})
# Enforce javascript formatting
for idx, setting in enumerate(settings):
key = setting['key']
value = setting['value']
settings[key.upper()] = cls.get_setting_default(key)
for key, value in settings.items():
validator = cls.get_setting_validator(key)
# Convert to javascript compatible booleans
if cls.validator_is_bool(validator):
value = str(value).lower()
# Numerical values remain the same
value = InvenTree.helpers.str2bool(value)
elif cls.validator_is_int(validator):
pass
try:
value = int(value)
except ValueError:
value = cls.get_setting_default(key)
# Wrap strings with quotes
else:
value = format_html("'{}'", value)
setting["value"] = value
settings[key] = value
return settings
@ -802,6 +788,44 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'description': _('Prefix value for purchase order reference'),
'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:
@ -1021,7 +1045,7 @@ class PriceBreak(models.Model):
try:
converted = convert_money(self.price, currency_code)
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 converted.amount

View File

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

View File

@ -501,6 +501,34 @@ class SupplierPart(models.Model):
'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,
related_name='supplier_parts',
verbose_name=_('Base Part'),

View File

@ -204,9 +204,9 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
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)
@ -231,6 +231,25 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'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):
""" Serializer for SupplierPriceBreak object """

View File

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

View File

@ -24,19 +24,17 @@
</button>
{% endif %}
<div class='btn-group'>
<div class="dropdown" style="float: right;">
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %}
</ul>
</div>
<button class="btn btn-primary dropdown-toggle" id='supplier-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a href='#' id='multi-supplier-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a href='#' id='multi-supplier-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %}
</ul>
</div>
</div>
<div class='filter-list' id='filter-list-supplier-part'>
@ -59,26 +57,24 @@
{% if roles.purchase_order.change %}
<div id='manufacturer-part-button-toolbar'>
<div class='button-toolbar container-fluid'>
<div class='btn-group role='group'>
<div class='btn-group' role='group'>
{% 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" %}
</button>
{% endif %}
<div class='btn-group'>
<div class="dropdown" style="float: right;">
<button class="btn btn-primary dropdown-toggle" id='table-options', type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %}
</ul>
</div>
<div class='btn-group' role='group'>
<button class="btn btn-primary dropdown-toggle" id='manufacturer-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a href='#' id='multi-manufacturer-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a href='#' id='multi-manufacturer-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %}
</ul>
</div>
</div>
<div class='filter-list' id='filter-list-supplier-part'>
@ -87,7 +83,7 @@
</div>
</div>
{% 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>
</div>
</div>
@ -109,7 +105,7 @@
{% if roles.purchase_order.add %}
<div id='po-button-bar'>
<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>
<div class='filter-list' id='filter-list-purchaseorder'>
<!-- Empty div -->
@ -131,7 +127,7 @@
{% if roles.sales_order.add %}
<div id='so-button-bar'>
<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" %}
</button>
<div class='filter-list' id='filter-list-salesorder'>
@ -274,6 +270,10 @@
{% if company.is_manufacturer %}
function reloadManufacturerPartTable() {
$('#manufacturer-part-table').bootstrapTable('refresh');
}
$("#manufacturer-part-create").click(function () {
createManufacturerPart({
@ -285,7 +285,7 @@
});
loadManufacturerPartTable(
"#part-table",
"#manufacturer-part-table",
"{% url 'api-manufacturer-part-list' %}",
{
params: {
@ -296,20 +296,20 @@
}
);
linkButtonsToSelection($("#manufacturer-table"), ['#table-options']);
linkButtonsToSelection($("#manufacturer-part-table"), ['#manufacturer-table-options']);
$("#multi-part-delete").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections");
$("#multi-manufacturer-part-delete").click(function() {
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
deleteManufacturerParts(selections, {
onSuccess: function() {
$("#part-table").bootstrapTable("refresh");
$("#manufacturer-part-table").bootstrapTable("refresh");
}
});
});
$("#multi-part-order").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections");
$("#multi-manufacturer-part-order").click(function() {
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
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 requests = [];
@ -379,8 +379,8 @@
);
});
$("#multi-part-order").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections");
$("#multi-supplier-part-order").click(function() {
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
var parts = [];
@ -395,6 +395,8 @@
});
});
{% endif %}
attachNavCallbacks({
name: 'company',
default: 'company-stock'

View File

@ -109,7 +109,7 @@ src="{% static 'img/blank_image.png' %}"
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
</ul>
@ -133,7 +133,7 @@ src="{% static 'img/blank_image.png' %}"
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='parameter-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<button id='parameter-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='multi-parameter-delete' title='{% trans "Delete parameters" %}'>{% trans "Delete" %}</a></li>
</ul>
@ -225,7 +225,7 @@ $("#multi-parameter-delete").click(function() {
<ul>`;
selections.forEach(function(item) {
text += `<li>${item.name} - <i>${item.value}</i></li>`;
text += `<li>${item.name} - <em>${item.value}</em></li>`;
});
text += `

View File

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

View File

@ -160,7 +160,7 @@ src="{% static 'img/blank_image.png' %}"
<div class='panel-content'>
{% if roles.purchase_order.add %}
<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" %}
</button>
</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 -*-
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
from rest_framework import generics
from rest_framework import filters, status
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import str2bool
from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
@ -27,6 +32,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
from .models import SalesOrderAttachment
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
from .serializers import SalesOrderAllocationSerializer
from .serializers import POReceiveSerializer
class POList(generics.ListCreateAPIView):
@ -144,7 +150,7 @@ class POList(generics.ListCreateAPIView):
return queryset
filter_backends = [
DjangoFilterBackend,
rest_filters.DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
@ -204,6 +210,111 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView):
return queryset
class POReceive(generics.CreateAPIView):
"""
API endpoint to receive stock items against a purchase order.
- The purchase order is specified in the URL.
- Items to receive are specified as a list called "items" with the following options:
- supplier_part: pk value of the supplier part
- quantity: quantity to receive
- status: stock item status
- location: destination for stock item (optional)
- A global location can also be specified
"""
queryset = PurchaseOrderLineItem.objects.none()
serializer_class = POReceiveSerializer
def get_serializer_context(self):
context = super().get_serializer_context()
# Pass the purchase order through to the serializer for validation
context['order'] = self.get_order()
return context
def get_order(self):
"""
Returns the PurchaseOrder associated with this API endpoint
"""
pk = self.kwargs.get('pk', None)
if pk is None:
return None
else:
order = PurchaseOrder.objects.get(pk=self.kwargs['pk'])
return order
def create(self, request, *args, **kwargs):
# Which purchase order are we receiving against?
self.order = self.get_order()
# Validate the serialized data
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Receive the line items
self.receive_items(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@transaction.atomic
def receive_items(self, serializer):
"""
Receive the items
At this point, much of the heavy lifting has been done for us by DRF serializers!
We have a list of "items", each a dict which contains:
- line_item: A PurchaseOrderLineItem matching this order
- location: A destination location
- quantity: A validated numerical quantity
- status: The status code for the received item
"""
data = serializer.validated_data
location = data['location']
items = data['items']
# Check if the location is not specified for any particular item
for item in items:
line = item['line_item']
if not item.get('location', None):
# If a global location is specified, use that
item['location'] = location
if not item['location']:
# The line item specifies a location?
item['location'] = line.get_destination()
if not item['location']:
raise ValidationError({
'location': _("Destination location must be specified"),
})
# Now we can actually receive the items
for item in items:
self.order.receive_line_item(
item['line_item'],
item['location'],
item['quantity'],
self.request.user,
status=item['status'],
barcode=item.get('barcode', ''),
)
class POLineItemList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of POLineItem objects
@ -214,6 +325,14 @@ class POLineItemList(generics.ListCreateAPIView):
queryset = PurchaseOrderLineItem.objects.all()
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):
try:
@ -226,18 +345,26 @@ class POLineItemList(generics.ListCreateAPIView):
return self.serializer_class(*args, **kwargs)
filter_backends = [
DjangoFilterBackend,
rest_filters.DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter
InvenTreeOrderingFilter
]
ordering_field_aliases = {
'MPN': 'part__manufacturer_part__MPN',
'SKU': 'part__SKU',
'part_name': 'part__part__name',
}
ordering_fields = [
'part__part__name',
'part__MPN',
'part__SKU',
'reference',
'MPN',
'part_name',
'purchase_price',
'quantity',
'received',
'reference',
'SKU',
'total_price',
]
search_fields = [
@ -262,6 +389,14 @@ class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = PurchaseOrderLineItem.objects.all()
serializer_class = POLineItemSerializer
def get_queryset(self):
queryset = super().get_queryset()
queryset = POLineItemSerializer.annotate_queryset(queryset)
return queryset
class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
"""
@ -272,7 +407,7 @@ class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
serializer_class = SOAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
rest_filters.DjangoFilterBackend,
]
filter_fields = [
@ -396,7 +531,7 @@ class SOList(generics.ListCreateAPIView):
return queryset
filter_backends = [
DjangoFilterBackend,
rest_filters.DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
@ -495,7 +630,7 @@ class SOLineItemList(generics.ListCreateAPIView):
return queryset
filter_backends = [
DjangoFilterBackend,
rest_filters.DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter
]
@ -580,7 +715,7 @@ class SOAllocationList(generics.ListCreateAPIView):
return queryset
filter_backends = [
DjangoFilterBackend,
rest_filters.DjangoFilterBackend,
]
# Default filterable fields
@ -598,7 +733,7 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
serializer_class = POAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
rest_filters.DjangoFilterBackend,
]
filter_fields = [
@ -616,13 +751,25 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
order_api_urls = [
# API endpoints for purchase orders
url(r'po/attachment/', include([
url(r'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'),
url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'),
url(r'^po/', include([
# Purchase order attachments
url(r'attachment/', include([
url(r'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'),
url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'),
])),
# Individual purchase order detail URLs
url(r'^(?P<pk>\d+)/', include([
url(r'^receive/', POReceive.as_view(), name='api-po-receive'),
url(r'.*$', PODetail.as_view(), name='api-po-detail'),
])),
# Purchase order list
url(r'^.*$', POList.as_view(), name='api-po-list'),
])),
url(r'^po/(?P<pk>\d+)/$', PODetail.as_view(), name='api-po-detail'),
url(r'^po/.*$', POList.as_view(), name='api-po-list'),
# API endpoints for purchase order line items
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),

View File

@ -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', '')
barcode = kwargs.get('barcode', '')
# Prevent null values for barcode
if barcode is None:
barcode = ''
if not self.status == PurchaseOrderStatus.PLACED:
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
@ -433,7 +438,8 @@ class PurchaseOrder(Order):
quantity=quantity,
purchase_order=self,
status=status,
purchase_price=purchase_price,
purchase_price=line.purchase_price,
uid=barcode
)
stock.save(add_note=False)
@ -729,7 +735,7 @@ class PurchaseOrderLineItem(OrderLineItem):
class Meta:
unique_together = (
('order', 'part')
('order', 'part', 'quantity', 'purchase_price')
)
def __str__(self):
@ -767,7 +773,13 @@ class PurchaseOrderLineItem(OrderLineItem):
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(
max_digits=19,
@ -778,7 +790,7 @@ class PurchaseOrderLineItem(OrderLineItem):
)
destination = TreeForeignKey(
'stock.StockLocation', on_delete=models.DO_NOTHING,
'stock.StockLocation', on_delete=models.SET_NULL,
verbose_name=_('Destination'),
related_name='po_lines',
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.db import models
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.serializers import ValidationError
from sql_util.utils import SubqueryCount
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeMoneySerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField
from InvenTree.status_codes import StockStatus
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from part.serializers import PartBriefSerializer
import stock.models
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
from .models import PurchaseOrder, PurchaseOrderLineItem
@ -108,6 +117,23 @@ class POSerializer(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):
part_detail = kwargs.pop('part_detail', False)
@ -118,10 +144,11 @@ class POLineItemSerializer(InvenTreeModelSerializer):
self.fields.pop('part_detail')
self.fields.pop('supplier_part_detail')
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0)
total_price = serializers.FloatField(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)
@ -157,10 +184,136 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'purchase_price_string',
'destination',
'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
"""
@ -174,6 +327,7 @@ class POAttachmentSerializer(InvenTreeModelSerializer):
'pk',
'order',
'attachment',
'filename',
'comment',
'upload_date',
]
@ -381,7 +535,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
]
class SOAttachmentSerializer(InvenTreeModelSerializer):
class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
Serializers for the SalesOrderAttachment model
"""
@ -395,6 +549,7 @@ class SOAttachmentSerializer(InvenTreeModelSerializer):
'pk',
'order',
'attachment',
'filename',
'comment',
'upload_date',
]

View File

@ -57,7 +57,7 @@
{% for duplicate in duplicates %}
{% if duplicate == col.value %}
<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>
{% endif %}
{% endfor %}

View File

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

View File

@ -38,7 +38,7 @@
<tr id='part_row_{{ part.id }}'>
<td>
{% 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>
<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>
</div>
{% 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 %}
</div>
</td>

View File

@ -19,7 +19,7 @@
<div class='panel-content'>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
{% 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" %}
</button>
<a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'>
@ -28,7 +28,7 @@
{% endif %}
</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>
</div>
</div>
@ -38,7 +38,7 @@
<h4>{% trans "Received Items" %}</h4>
</div>
<div class='panel-content'>
{% include "stock_table.html" with read_only=True %}
{% include "stock_table.html" with prevent_new_stock=True %}
</div>
</div>
@ -122,6 +122,7 @@
constructForm(url, {
fields: {
filename: {},
comment: {},
},
onSuccess: reloadAttachmentTable,
@ -200,255 +201,32 @@ $('#new-po-line').click(function() {
},
method: 'POST',
title: '{% trans "Add Line Item" %}',
onSuccess: reloadTable,
onSuccess: function() {
$('#po-line-table').bootstrapTable('refresh');
},
});
});
{% endif %}
function reloadTable() {
$("#po-table").bootstrapTable("refresh");
}
function setupCallbacks() {
// Setup callbacks for the line buttons
var table = $("#po-table");
loadPurchaseOrderLineItemTable('#po-line-table', {
order: {{ order.pk }},
supplier: {{ order.supplier.pk }},
{% if order.status == PurchaseOrderStatus.PENDING %}
table.find(".button-line-edit").click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/po-line/${pk}/`, {
fields: {
part: {
filters: {
part_detail: true,
supplier_detail: true,
supplier: {{ order.supplier.pk }},
}
},
quantity: {},
reference: {},
purchase_price: {},
purchase_price_currency: {},
destination: {},
notes: {},
},
title: '{% trans "Edit Line Item" %}',
onSuccess: reloadTable,
});
});
table.find(".button-line-delete").click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/po-line/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Line Item" %}',
onSuccess: reloadTable,
});
});
allow_edit: true,
{% else %}
allow_edit: false,
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
allow_receive: true,
{% else %}
allow_receive: false,
{% endif %}
table.find(".button-line-receive").click(function() {
var pk = $(this).attr('pk');
launchModalForm("{% url 'po-receive' order.id %}", {
success: reloadTable,
data: {
line: pk,
},
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
});
}
$("#po-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 (row.received < row.quantity) {
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
}
{% endif %}
html += `</div>`;
return html;
},
}
]
});
attachNavCallbacks({
name: 'purchase-order',
default: 'order-items'
});
attachNavCallbacks({
name: 'purchase-order',
default: 'order-items'
});
{% endblock %}

View File

@ -17,7 +17,7 @@
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
{% 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" %}
</button>
{% endif %}

View File

@ -5,7 +5,7 @@
{% 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'>
{% csrf_token %}

View File

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

View File

@ -22,7 +22,7 @@
{% endif %}
<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>
{% trans "Shipping this order means that the order will no longer be editable." %}
</div>

View File

@ -17,7 +17,7 @@
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
{% 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" %}
</button>
{% endif %}

View File

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

View File

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

View File

@ -9,12 +9,14 @@ from django.conf.urls import url, include
from django.urls import reverse
from django.http import JsonResponse
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 rest_framework import status
from rest_framework.response import Response
from rest_framework import filters, serializers
from rest_framework import generics
from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
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.exceptions import MissingRate
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from .models import Part, PartCategory, BomItem
from .models import PartParameter, PartParameterTemplate
@ -31,7 +33,10 @@ from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
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 build.models import Build
@ -630,6 +635,7 @@ class PartList(generics.ListCreateAPIView):
else:
return Response(data)
@transaction.atomic
def create(self, request, *args, **kwargs):
"""
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
"""
# TODO: Unit tests for this function!
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
@ -680,21 +688,97 @@ class PartList(generics.ListCreateAPIView):
pass
# Optionally create initial stock item
try:
initial_stock = Decimal(request.data.get('initial_stock', 0))
initial_stock = str2bool(request.data.get('initial_stock', False))
if initial_stock > 0 and part.default_location is not None:
if initial_stock:
try:
stock_item = StockItem(
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(
part=part,
quantity=initial_stock_quantity,
location=initial_stock_location,
)
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:
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,
quantity=initial_stock,
location=part.default_location,
manufacturer=manufacturer,
MPN=mpn
)
else:
# No manufacturer part data specified
manufacturer_part = None
stock_item.save(user=request.user)
if supplier or sku:
if not supplier:
raise ValidationError({
'supplier': [_("This field is required")]
})
if not sku:
raise ValidationError({
'SKU': [_("This field is required")]
})
except:
pass
SupplierPart.objects.create(
part=part,
supplier=supplier,
SKU=sku,
manufacturer_part=manufacturer_part,
)
headers = self.get_success_headers(serializer.data)

View File

@ -35,6 +35,8 @@ from stdimage.models import StdImageField
from decimal import Decimal, InvalidOperation
from datetime import datetime
import hashlib
from djmoney.contrib.exchange.models import convert_money
from common.settings import currency_code_default
from InvenTree import helpers
from InvenTree import validators
@ -1514,7 +1516,7 @@ class Part(MPTTModel):
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.
Adds the minimum price for all components in the BOM.
@ -1531,7 +1533,7 @@ class Part(MPTTModel):
print("Warning: Item contains itself in BOM")
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:
continue
@ -1555,16 +1557,17 @@ class Part(MPTTModel):
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:
- Supplier price (if purchased from suppliers)
- BOM price (if built from other parts)
- Internal price (if set for the part)
- Purchase price (if set for the part)
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
@ -1572,6 +1575,12 @@ class Part(MPTTModel):
internal_price = self.get_internal_price(quantity)
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
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):
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
def copy_bom_from(self, other, clear=True, **kwargs):
"""

View File

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

View File

@ -11,13 +11,13 @@
<div class='alert alert-block alert-info'>
{% else %}
<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 %}
{% 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>
{% else %}
<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>
{% endif %}

View File

@ -9,7 +9,7 @@
{% if part.has_bom %}
<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>
</div>
{% endif %}

View File

@ -57,7 +57,7 @@
{% for duplicate in duplicates %}
{% if duplicate == col.value %}
<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>
{% endif %}
{% endfor %}

View File

@ -43,9 +43,9 @@
{% block form_alert %}
<div class='alert alert-info alert-block'>
<b>{% trans "Requirements for BOM upload" %}:</b>
<strong>{% trans "Requirements for BOM upload" %}:</strong>
<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>
</ul>
</div>

View File

@ -3,7 +3,7 @@
{% load i18n %}
{% 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'>
{% trans 'This will validate each line in the BOM.' %}

View File

@ -132,12 +132,13 @@
</button>
{% endif %}
<div class='btn-group'>
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %}<span class='caret'></span></button>
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %} <span class='caret'></span></button>
<ul class='dropdown-menu'>
{% if roles.part.change %}
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
{% endif %}
<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>
</ul>
</div>
@ -276,6 +277,7 @@
constructForm('{% url "api-part-list" %}', {
method: 'POST',
fields: fields,
groups: partGroups(),
title: '{% trans "Create Part" %}',
onSuccess: function(data) {
// Follow the new part

View File

@ -8,13 +8,13 @@
{% if matches %}
<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>
<ul class='list-group'>
{% for match in matches %}
<li class='list-group-item list-group-item-condensed'>
{% 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>
{% endfor %}
</ul>

View File

@ -18,7 +18,7 @@
<div class='panel-content'>
{% if part.is_template %}
<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>
{% endif %}
{% include "stock_table.html" %}
@ -74,7 +74,7 @@
<div id='so-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'>
{% 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 %}
<div class='filter-list' id='filter-list-salesorder'>
<!-- An empty div in which the filter list will be constructed -->
@ -185,7 +185,7 @@
<div id='related-button-bar'>
<div class='button-toolbar container-fluid' style='float: left;'>
{% 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'>
<!-- An empty div in which the filter list will be constructed -->
</div>
@ -289,7 +289,7 @@
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
</ul>
@ -312,7 +312,7 @@
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='manufacturer-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<button id='manufacturer-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'>{% trans "Delete" %}</a></li>
</ul>
@ -667,6 +667,8 @@
});
onPanelLoad("test-templates", function() {
// Load test template table
loadPartTestTemplateTable(
$("#test-template-table"),
{
@ -677,12 +679,9 @@
}
);
// Callback for "add test template" button
$("#add-test-template").click(function() {
function reloadTestTemplateTable() {
$("#test-template-table").bootstrapTable("refresh");
}
constructForm('{% url "api-part-test-template-list" %}', {
method: 'POST',
fields: {
@ -697,39 +696,10 @@
}
},
title: '{% trans "Add Test Result Template" %}',
onSuccess: reloadTestTemplateTable
onSuccess: function() {
$("#test-template-table").bootstrapTable("refresh");
}
});
$("#test-template-table").on('click', '.button-test-edit', function() {
var pk = $(this).attr('pk');
var url = `/api/part/test-template/${pk}/`;
constructForm(url, {
fields: {
test_name: {},
description: {},
required: {},
requires_value: {},
requires_attachment: {},
},
title: '{% trans "Edit Test Result Template" %}',
onSuccess: reloadTestTemplateTable,
});
});
$("#test-template-table").on('click', '.button-test-delete', function() {
var pk = $(this).attr('pk');
var url = `/api/part/test-template/${pk}/`;
constructForm(url, {
method: 'DELETE',
title: '{% trans "Delete Test Result Template" %}',
onSuccess: reloadTestTemplateTable,
});
});
});
});
@ -868,6 +838,7 @@
constructForm(url, {
fields: {
filename: {},
comment: {},
},
title: '{% trans "Edit Attachment" %}',

View File

@ -50,7 +50,7 @@
{% for duplicate in duplicates %}
{% if duplicate == col.value %}
<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>
{% endif %}
{% endfor %}

View File

@ -57,7 +57,7 @@
{% for duplicate in duplicates %}
{% if duplicate == col.value %}
<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>
{% endif %}
{% endfor %}

View File

@ -1,8 +1,24 @@
{% extends "base.html" %}
{% extends "part/part_app_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% 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 %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
@ -54,4 +70,9 @@
{% block js_ready %}
{{ block.super }}
enableNavbar({
label: 'part',
toggleId: '#part-menu-toggle',
});
{% endblock %}

View File

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

View File

@ -10,6 +10,11 @@
{% 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">
<!-- Default panel contents -->
<div class="panel-heading"><h3>{{ part.full_name }}</h3></div>
@ -80,10 +85,12 @@
</div>
{% endif %}
{% if part.active %}
{% if enable_buy or enable_sell %}
<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'/>
</button>
{% if roles.stock.change %}
{% endif %}
{% if roles.stock.change and enable_stock %}
<div class='btn-group'>
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-boxes'></span> <span class='caret'></span>
@ -104,8 +111,8 @@
</ul>
</div>
{% endif %}
{% if part.purchaseable %}
{% if roles.purchase_order.add %}
{% if part.purchaseable and roles.purchase_order.add %}
{% if enable_buy and enable_po %}
<button type='button' class='btn btn-default' id='part-order' title='{% trans "Order part" %}'>
<span id='part-order-icon' class='fas fa-shopping-cart'/>
</button>
@ -123,7 +130,7 @@
{% if roles.part.change %}
<li><a href='#' id='part-edit'><span class='fas fa-edit icon-blue'></span> {% trans "Edit part" %}</a></li>
{% 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>
{% endif %}
</ul>
@ -496,12 +503,13 @@
});
{% endif %}
{% if not part.active and roles.part.delete %}
{% if roles.part.delete %}
$("#part-delete").click(function() {
launchModalForm(
"{% 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'>
<tr>
<td><b>{% trans 'Part' %}</b></td>
<td><strong>{% trans 'Part' %}</strong></td>
<td>{{ part }}</td>
</tr>
<tr>
<td><b>{% trans 'Quantity' %}</b></td>
<td><strong>{% trans 'Quantity' %}</strong></td>
<td>{{ quantity }}</td>
</tr>
</table>
@ -23,13 +23,13 @@
<table class='table table-striped table-condensed table-price-three'>
{% if min_total_buy_price %}
<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>Max: {% include "price.html" with price=max_unit_buy_price %}</td>
</tr>
{% if quantity > 1 %}
<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>Max: {% include "price.html" with price=max_total_buy_price %}</td>
</tr>
@ -37,7 +37,7 @@
{% else %}
<tr>
<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>
</tr>
{% endif %}
@ -49,28 +49,43 @@
<table class='table table-striped table-condensed table-price-three'>
{% if min_total_bom_price %}
<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>Max: {% include "price.html" with price=max_unit_bom_price %}</td>
</tr>
{% if quantity > 1 %}
<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>Max: {% include "price.html" with price=max_total_bom_price %}</td>
</tr>
{% 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 %}
<tr>
<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>
</tr>
{% endif %}
{% else %}
<tr>
<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>
</tr>
{% endif %}
@ -82,11 +97,11 @@
<h4>{% trans 'Internal Price' %}</h4>
<table class='table table-striped table-condensed table-price-two'>
<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>
</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>
</tr>
</table>
@ -97,11 +112,11 @@
<h4>{% trans 'Sale Price' %}</h4>
<table class='table table-striped table-condensed table-price-two'>
<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>
</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>
</tr>
</table>

View File

@ -3,8 +3,18 @@
{% block pre_form_content %}
{% if part.active %}
<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>
{% 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>
{% endif %}
{% endif %}
{% endblock %}
{% block form %}
{% if not part.active %}
{{ block.super }}
{% endif %}
{% endblock %}

View File

@ -18,7 +18,7 @@
{% if part.supplier_count > 0 %}
{% if min_total_buy_price %}
<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="#purchase-price" title='{% trans "Show purchase price" %}'><span class="fas fa-chart-bar"></span></a>
</td>
@ -37,7 +37,7 @@
{% else %}
<tr>
<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>
</tr>
{% endif %}
@ -46,7 +46,7 @@
{% if part.bom_count > 0 %}
{% if min_total_bom_price %}
<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>
</td>
<td>{% trans 'Unit Cost' %}</td>
@ -61,17 +61,36 @@
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
</tr>
{% 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 %}
<tr>
<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>
</tr>
{% endif %}
{% else %}
<tr>
<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>
</tr>
{% endif %}
@ -80,7 +99,7 @@
{% if show_internal_price and roles.sales_order.view %}
{% if total_internal_part_price %}
<tr>
<td><b>{% trans 'Internal Price' %}</b></td>
<td><strong>{% trans 'Internal Price' %}</strong></td>
<td>{% trans 'Unit Cost' %}</td>
<td colspan='2'>{% include "price.html" with price=unit_internal_part_price %}</td>
</tr>
@ -94,7 +113,7 @@
{% if total_part_price %}
<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-price" title='{% trans "Show sale price" %}'><span class="fas fa-chart-bar"></span></a>
</td>
@ -160,8 +179,7 @@
</div>
<div class='panel-content'>
<h4>{% trans 'Stock Pricing' %}
<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
The Supplier Unit Cost is the current purchase price for that supplier part."></i>
<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>
</h4>
{% if price_history|length > 0 %}
<div style="max-width: 99%; min-height: 300px">
@ -193,7 +211,7 @@
</div>
<div class="col col-md-4">
<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" %}
</button>
</div>
@ -249,7 +267,7 @@
</div>
<div class="col col-md-4">
<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" %}
</button>
</div>

View File

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

View File

@ -6,6 +6,7 @@ over and above the built-in Django tags.
import os
import sys
from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _
from django.conf import settings as djangosettings
@ -135,6 +136,21 @@ def inventree_version(*args, **kwargs):
return version.inventreeVersion()
@register.simple_tag()
def inventree_is_development(*args, **kwargs):
return version.isInvenTreeDevelopmentVersion()
@register.simple_tag()
def inventree_is_release(*args, **kwargs):
return not version.isInvenTreeDevelopmentVersion()
@register.simple_tag()
def inventree_docs_version(*args, **kwargs):
return version.inventreeDocsVersion()
@register.simple_tag()
def inventree_api_version(*args, **kwargs):
""" Return InvenTree API version """
@ -168,7 +184,10 @@ def inventree_github_url(*args, **kwargs):
@register.simple_tag()
def inventree_docs_url(*args, **kwargs):
""" Return URL for InvenTree documenation site """
return "https://inventree.readthedocs.io/"
tag = version.inventreeDocsVersion()
return f"https://inventree.readthedocs.io/en/{tag}"
@register.simple_tag()
@ -262,6 +281,26 @@ def get_available_themes(*args, **kwargs):
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
def keyvalue(dict, key):
"""

View File

@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase):
'location',
'bom',
'test_templates',
'company',
]
roles = [
@ -465,6 +466,149 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertFalse(response.data['active'])
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):
"""

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