mirror of
https://github.com/inventree/InvenTree.git
synced 2026-06-13 12:00:51 +00:00
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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*
|
||||||
@@ -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"
|
||||||
@@ -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).
|
||||||
@@ -15,6 +15,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
- name: Check version number
|
||||||
|
run: |
|
||||||
|
python3 ci/check_version_number.py --dev
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Build and push latest docker image on push to master branch
|
||||||
|
|
||||||
|
name: Docker Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'stable'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Check version number
|
||||||
|
run: |
|
||||||
|
python3 ci/check_version_number.py --release
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
- name: Login to Dockerhub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Build and Push
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: ./docker
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
push: true
|
||||||
|
target: production
|
||||||
|
build-args:
|
||||||
|
branch: stable
|
||||||
|
repository: inventree/inventree
|
||||||
|
tags: inventree/inventree:stable
|
||||||
|
- name: Image Digest
|
||||||
|
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Check Release tag
|
- name: Check Release tag
|
||||||
run: |
|
run: |
|
||||||
python3 ci/check_version_number.py ${{ github.event.release.tag_name }}
|
python3 ci/check_version_number.py --release --tag ${{ github.event.release.tag_name }}
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -32,5 +32,7 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
push: true
|
push: true
|
||||||
target: production
|
target: production
|
||||||
|
build-args:
|
||||||
|
tag: ${{ github.event.release.tag_name }}
|
||||||
repository: inventree/inventree
|
repository: inventree/inventree
|
||||||
tags: inventree/inventree:${{ github.event.release.tag_name }}
|
tags: inventree/inventree:${{ github.event.release.tag_name }}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -18,11 +18,33 @@ jobs:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
INVENTREE_DB_ENGINE: sqlite3
|
||||||
|
INVENTREE_DB_NAME: inventree
|
||||||
|
INVENTREE_MEDIA_ROOT: ./media
|
||||||
|
INVENTREE_STATIC_ROOT: ./static
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
- run: npm install
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Check Files
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: 3.7
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install gettext
|
||||||
|
pip3 install invoke
|
||||||
|
invoke install
|
||||||
|
invoke static
|
||||||
|
- name: Check Templated Files
|
||||||
run: |
|
run: |
|
||||||
cd ci
|
cd ci
|
||||||
python check_js_templates.py
|
python check_js_templates.py
|
||||||
|
- name: Lint Javascript Files
|
||||||
|
run: |
|
||||||
|
npm install eslint eslint-config-google
|
||||||
|
invoke render-js-files
|
||||||
|
npx eslint js_tmp/*.js
|
||||||
@@ -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 }}
|
||||||
@@ -37,6 +37,7 @@ local_settings.py
|
|||||||
|
|
||||||
# Files used for testing
|
# Files used for testing
|
||||||
dummy_image.*
|
dummy_image.*
|
||||||
|
_tmp.csv
|
||||||
|
|
||||||
# Sphinx files
|
# Sphinx files
|
||||||
docs/_build
|
docs/_build
|
||||||
@@ -66,8 +67,16 @@ secret_key.txt
|
|||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
|
# Temporary javascript files (used for testing)
|
||||||
|
js_tmp/
|
||||||
|
|
||||||
# Development files
|
# Development files
|
||||||
dev/
|
dev/
|
||||||
|
|
||||||
# Locale stats file
|
# Locale stats file
|
||||||
locale_stats.json
|
locale_stats.json
|
||||||
|
|
||||||
|
# node.js
|
||||||
|
package-lock.json
|
||||||
|
package.json
|
||||||
|
node_modules/
|
||||||
+85
-12
@@ -1,29 +1,102 @@
|
|||||||
Contributions to InvenTree are welcomed - please follow the guidelines below.
|
Please read the contribution guidelines below, before submitting your first pull request to the InvenTree codebase.
|
||||||
|
|
||||||
## Feature Branches
|
## Branches and Versioning
|
||||||
|
|
||||||
No pushing to master! New featues must be submitted in a separate branch (one branch per feature).
|
InvenTree roughly follow the [GitLab flow](https://docs.gitlab.com/ee/topics/gitlab_flow.html) branching style, to allow simple management of multiple tagged releases, short-lived branches, and development on the main branch.
|
||||||
|
|
||||||
## Include Migration Files
|
### Version Numbering
|
||||||
|
|
||||||
|
InvenTree version numbering follows the [semantic versioning](https://semver.org/) specification.
|
||||||
|
|
||||||
|
### Master Branch
|
||||||
|
|
||||||
|
The HEAD of the "main" or "master" branch of InvenTree represents the current "latest" state of code development.
|
||||||
|
|
||||||
|
- All feature branches are merged into master
|
||||||
|
- All bug fixes are merged into master
|
||||||
|
|
||||||
|
**No pushing to master:** New featues must be submitted as a pull request from a separate branch (one branch per feature).
|
||||||
|
|
||||||
|
#### Feature Branches
|
||||||
|
|
||||||
|
Feature branches should be branched *from* the *master* branch.
|
||||||
|
|
||||||
|
- One major feature per branch / pull request
|
||||||
|
- Feature pull requests are merged back *into* the master branch
|
||||||
|
- Features *may* also be merged into a release candidate branch
|
||||||
|
|
||||||
|
### Stable Branch
|
||||||
|
|
||||||
|
The HEAD of the "stable" branch represents the latest stable release code.
|
||||||
|
|
||||||
|
- Versioned releases are merged into the "stable" branch
|
||||||
|
- Bug fix branches are made *from* the "stable" branch
|
||||||
|
|
||||||
|
#### Release Candidate Branches
|
||||||
|
|
||||||
|
- Release candidate branches are made from master, and merged into stable.
|
||||||
|
- RC branches are targetted at a major/minor version e.g. "0.5"
|
||||||
|
- When a release candidate branch is merged into *stable*, the release is tagged
|
||||||
|
|
||||||
|
#### Bugfix Branches
|
||||||
|
|
||||||
|
- If a bug is discovered in a tagged release version of InvenTree, a "bugfix" or "hotfix" branch should be made *from* that tagged release
|
||||||
|
- When approved, the branch is merged back *into* stable, with an incremented PATCH number (e.g. 0.4.1 -> 0.4.2)
|
||||||
|
- The bugfix *must* also be cherry picked into the *master* branch.
|
||||||
|
|
||||||
|
## Migration Files
|
||||||
|
|
||||||
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `invoke migrate` and commit the migration files before submitting the PR.
|
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `invoke migrate` and commit the migration files before submitting the PR.
|
||||||
|
|
||||||
## Update Translation Files
|
*Note: A github action checks for unstaged migration files and will reject the PR if it finds any!*
|
||||||
|
|
||||||
Any PRs which update translatable strings (i.e. text strings that will appear in the web-front UI) must also update the translation (locale) files to include hooks for the translated strings.
|
## Unit Testing
|
||||||
|
|
||||||
*This does not mean that all translations must be provided, but that the translation files must include locations for the translated strings to be written.*
|
Any new code should be covered by unit tests - a submitted PR may not be accepted if the code coverage for any new features is insufficient, or the overall code coverage is decreased.
|
||||||
|
|
||||||
To perform this step, simply run `invoke translate` from the top level directory before submitting the PR.
|
The InvenTree code base makes use of [GitHub actions](https://github.com/features/actions) to run a suite of automated tests against the code base every time a new pull request is received. These actions include (but are not limited to):
|
||||||
|
|
||||||
## Testing
|
- Checking Python and Javascript code against standard style guides
|
||||||
|
- Running unit test suite
|
||||||
|
- Automated building and pushing of docker images
|
||||||
|
- Generating translation files
|
||||||
|
|
||||||
Any new code should be covered by unit tests - a submitted PR may not be accepted if the code coverage is decreased.
|
The various github actions can be found in the `./github/workflows` directory
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
New features or updates to existing features should be accompanied by user documentation. A PR with associated documentation should link to the matching PR at https://github.com/inventree/inventree-docs/
|
New features or updates to existing features should be accompanied by user documentation. A PR with associated documentation should link to the matching PR at https://github.com/inventree/inventree-docs/
|
||||||
|
|
||||||
## Code Style
|
## Translations
|
||||||
|
|
||||||
Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR.
|
Any user-facing strings *must* be passed through the translation engine.
|
||||||
|
|
||||||
|
- InvenTree code is written in English
|
||||||
|
- User translatable strings are provided in English as the primary language
|
||||||
|
- Secondary language translations are provided [via Crowdin](https://crowdin.com/project/inventree)
|
||||||
|
|
||||||
|
*Note: Translation files are updated via GitHub actions - you do not need to compile translations files before submitting a pull request!*
|
||||||
|
|
||||||
|
### Python Code
|
||||||
|
|
||||||
|
For strings exposed via Python code, use the following format:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
user_facing_string = _('This string will be exposed to the translation engine!')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Templated Strings
|
||||||
|
|
||||||
|
HTML and javascript files are passed through the django templating engine. Translatable strings are implemented as follows:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<span>{% trans "This string will be translated" %} - this string will not!</span>
|
||||||
|
```
|
||||||
@@ -32,27 +32,44 @@ class InvenTreeConfig(AppConfig):
|
|||||||
|
|
||||||
logger.info("Starting background tasks...")
|
logger.info("Starting background tasks...")
|
||||||
|
|
||||||
|
# Remove successful task results from the database
|
||||||
InvenTree.tasks.schedule_task(
|
InvenTree.tasks.schedule_task(
|
||||||
'InvenTree.tasks.delete_successful_tasks',
|
'InvenTree.tasks.delete_successful_tasks',
|
||||||
schedule_type=Schedule.DAILY,
|
schedule_type=Schedule.DAILY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check for InvenTree updates
|
||||||
InvenTree.tasks.schedule_task(
|
InvenTree.tasks.schedule_task(
|
||||||
'InvenTree.tasks.check_for_updates',
|
'InvenTree.tasks.check_for_updates',
|
||||||
schedule_type=Schedule.DAILY
|
schedule_type=Schedule.DAILY
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Heartbeat to let the server know the background worker is running
|
||||||
InvenTree.tasks.schedule_task(
|
InvenTree.tasks.schedule_task(
|
||||||
'InvenTree.tasks.heartbeat',
|
'InvenTree.tasks.heartbeat',
|
||||||
schedule_type=Schedule.MINUTES,
|
schedule_type=Schedule.MINUTES,
|
||||||
minutes=15
|
minutes=15
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Keep exchange rates up to date
|
||||||
InvenTree.tasks.schedule_task(
|
InvenTree.tasks.schedule_task(
|
||||||
'InvenTree.tasks.update_exchange_rates',
|
'InvenTree.tasks.update_exchange_rates',
|
||||||
schedule_type=Schedule.DAILY,
|
schedule_type=Schedule.DAILY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Remove expired sessions
|
||||||
|
InvenTree.tasks.schedule_task(
|
||||||
|
'InvenTree.tasks.delete_expired_sessions',
|
||||||
|
schedule_type=Schedule.DAILY,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete "old" stock items
|
||||||
|
InvenTree.tasks.schedule_task(
|
||||||
|
'stock.tasks.delete_old_stock_items',
|
||||||
|
schedule_type=Schedule.MINUTES,
|
||||||
|
minutes=30,
|
||||||
|
)
|
||||||
|
|
||||||
def update_exchange_rates(self):
|
def update_exchange_rates(self):
|
||||||
"""
|
"""
|
||||||
Update exchange rates each time the server is started, *if*:
|
Update exchange rates each time the server is started, *if*:
|
||||||
|
|||||||
@@ -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.")
|
||||||
@@ -36,9 +36,14 @@ def health_status(request):
|
|||||||
'email_configured': InvenTree.status.is_email_configured(),
|
'email_configured': InvenTree.status.is_email_configured(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The following keys are required to denote system health
|
||||||
|
health_keys = [
|
||||||
|
'django_q_running',
|
||||||
|
]
|
||||||
|
|
||||||
all_healthy = True
|
all_healthy = True
|
||||||
|
|
||||||
for k in status.keys():
|
for k in health_keys:
|
||||||
if status[k] is not True:
|
if status[k] is not True:
|
||||||
all_healthy = False
|
all_healthy = False
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"de": 95, "el": 0, "en": 0, "es": 4, "fr": 6, "he": 0, "id": 0, "it": 0, "ja": 4, "ko": 0, "nl": 0, "no": 0, "pl": 27, "ru": 6, "sv": 0, "th": 0, "tr": 32, "vi": 0, "zh": 1}
|
||||||
@@ -98,10 +98,12 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
|
|
||||||
serializer_info = super().get_serializer_info(serializer)
|
serializer_info = super().get_serializer_info(serializer)
|
||||||
|
|
||||||
try:
|
model_class = None
|
||||||
ModelClass = serializer.Meta.model
|
|
||||||
|
|
||||||
model_fields = model_meta.get_field_info(ModelClass)
|
try:
|
||||||
|
model_class = serializer.Meta.model
|
||||||
|
|
||||||
|
model_fields = model_meta.get_field_info(model_class)
|
||||||
|
|
||||||
# Iterate through simple fields
|
# Iterate through simple fields
|
||||||
for name, field in model_fields.fields.items():
|
for name, field in model_fields.fields.items():
|
||||||
@@ -146,11 +148,23 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
if hasattr(serializer, 'instance'):
|
if hasattr(serializer, 'instance'):
|
||||||
instance = serializer.instance
|
instance = serializer.instance
|
||||||
|
|
||||||
if instance is None:
|
if instance is None and model_class is not None:
|
||||||
try:
|
# Attempt to find the instance based on kwargs lookup
|
||||||
instance = self.view.get_object()
|
kwargs = getattr(self.view, 'kwargs', None)
|
||||||
except:
|
|
||||||
pass
|
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:
|
if instance is not None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ Generic models which provide extra functionality over base Django model types.
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -21,6 +23,9 @@ from mptt.exceptions import InvalidMove
|
|||||||
from .validators import validate_tree_name
|
from .validators import validate_tree_name
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
def rename_attachment(instance, filename):
|
def rename_attachment(instance, filename):
|
||||||
"""
|
"""
|
||||||
Function for renaming an attachment file.
|
Function for renaming an attachment file.
|
||||||
@@ -77,6 +82,72 @@ class InvenTreeAttachment(models.Model):
|
|||||||
def basename(self):
|
def basename(self):
|
||||||
return os.path.basename(self.attachment.name)
|
return os.path.basename(self.attachment.name)
|
||||||
|
|
||||||
|
@basename.setter
|
||||||
|
def basename(self, fn):
|
||||||
|
"""
|
||||||
|
Function to rename the attachment file.
|
||||||
|
|
||||||
|
- Filename cannot be empty
|
||||||
|
- Filename cannot contain illegal characters
|
||||||
|
- Filename must specify an extension
|
||||||
|
- Filename cannot match an existing file
|
||||||
|
"""
|
||||||
|
|
||||||
|
fn = fn.strip()
|
||||||
|
|
||||||
|
if len(fn) == 0:
|
||||||
|
raise ValidationError(_('Filename must not be empty'))
|
||||||
|
|
||||||
|
attachment_dir = os.path.join(
|
||||||
|
settings.MEDIA_ROOT,
|
||||||
|
self.getSubdir()
|
||||||
|
)
|
||||||
|
|
||||||
|
old_file = os.path.join(
|
||||||
|
settings.MEDIA_ROOT,
|
||||||
|
self.attachment.name
|
||||||
|
)
|
||||||
|
|
||||||
|
new_file = os.path.join(
|
||||||
|
settings.MEDIA_ROOT,
|
||||||
|
self.getSubdir(),
|
||||||
|
fn
|
||||||
|
)
|
||||||
|
|
||||||
|
new_file = os.path.abspath(new_file)
|
||||||
|
|
||||||
|
# Check that there are no directory tricks going on...
|
||||||
|
if not os.path.dirname(new_file) == attachment_dir:
|
||||||
|
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
|
||||||
|
raise ValidationError(_("Invalid attachment directory"))
|
||||||
|
|
||||||
|
# Ignore further checks if the filename is not actually being renamed
|
||||||
|
if new_file == old_file:
|
||||||
|
return
|
||||||
|
|
||||||
|
forbidden = ["'", '"', "#", "@", "!", "&", "^", "<", ">", ":", ";", "/", "\\", "|", "?", "*", "%", "~", "`"]
|
||||||
|
|
||||||
|
for c in forbidden:
|
||||||
|
if c in fn:
|
||||||
|
raise ValidationError(_(f"Filename contains illegal character '{c}'"))
|
||||||
|
|
||||||
|
if len(fn.split('.')) < 2:
|
||||||
|
raise ValidationError(_("Filename missing extension"))
|
||||||
|
|
||||||
|
if not os.path.exists(old_file):
|
||||||
|
logger.error(f"Trying to rename attachment '{old_file}' which does not exist")
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.path.exists(new_file):
|
||||||
|
raise ValidationError(_("Attachment with this filename already exists"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.rename(old_file, new_file)
|
||||||
|
self.attachment.name = os.path.join(self.getSubdir(), fn)
|
||||||
|
self.save()
|
||||||
|
except:
|
||||||
|
raise ValidationError(_("Error renaming file"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import os
|
|||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
@@ -46,10 +48,12 @@ class InvenTreeMoneySerializer(MoneyField):
|
|||||||
amount = None
|
amount = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if amount is not None:
|
if amount is not None and amount is not empty:
|
||||||
amount = Decimal(amount)
|
amount = Decimal(amount)
|
||||||
except:
|
except:
|
||||||
raise ValidationError(_("Must be a valid number"))
|
raise ValidationError({
|
||||||
|
self.field_name: _("Must be a valid number")
|
||||||
|
})
|
||||||
|
|
||||||
currency = data.get(get_currency_field_name(self.field_name), self.default_currency)
|
currency = data.get(get_currency_field_name(self.field_name), self.default_currency)
|
||||||
|
|
||||||
@@ -92,9 +96,14 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
# If instance is None, we are creating a new instance
|
# If instance is None, we are creating a new instance
|
||||||
if instance is None and data is not empty:
|
if instance is None and data is not empty:
|
||||||
|
|
||||||
# Required to side-step immutability of a QueryDict
|
if data is None:
|
||||||
data = data.copy()
|
data = OrderedDict()
|
||||||
|
else:
|
||||||
|
new_data = OrderedDict()
|
||||||
|
new_data.update(data)
|
||||||
|
|
||||||
|
data = new_data
|
||||||
|
|
||||||
# Add missing fields which have default values
|
# Add missing fields which have default values
|
||||||
ModelClass = self.Meta.model
|
ModelClass = self.Meta.model
|
||||||
@@ -167,6 +176,18 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
return self.instance
|
return self.instance
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""
|
||||||
|
Catch any django ValidationError, and re-throw as a DRF ValidationError
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
instance = super().update(instance, validated_data)
|
||||||
|
except (ValidationError, DjangoValidationError) as exc:
|
||||||
|
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
def run_validation(self, data=empty):
|
def run_validation(self, data=empty):
|
||||||
"""
|
"""
|
||||||
Perform serializer validation.
|
Perform serializer validation.
|
||||||
@@ -188,7 +209,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
# Update instance fields
|
# Update instance fields
|
||||||
for attr, value in data.items():
|
for attr, value in data.items():
|
||||||
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.
|
# Run a 'full_clean' on the model.
|
||||||
# Note that by default, DRF does *not* perform full model validation!
|
# Note that by default, DRF does *not* perform full model validation!
|
||||||
@@ -208,6 +232,22 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
|
||||||
|
"""
|
||||||
|
Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
|
||||||
|
|
||||||
|
The only real addition here is that we support "renaming" of the attachment file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The 'filename' field must be present in the serializer
|
||||||
|
filename = serializers.CharField(
|
||||||
|
label=_('Filename'),
|
||||||
|
required=False,
|
||||||
|
source='basename',
|
||||||
|
allow_blank=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||||
"""
|
"""
|
||||||
Override the DRF native FileField serializer,
|
Override the DRF native FileField serializer,
|
||||||
|
|||||||
@@ -169,6 +169,30 @@ else:
|
|||||||
logger.exception(f"Couldn't load keyfile {key_file}")
|
logger.exception(f"Couldn't load keyfile {key_file}")
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
|
# The filesystem location for served static files
|
||||||
|
STATIC_ROOT = os.path.abspath(
|
||||||
|
get_setting(
|
||||||
|
'INVENTREE_STATIC_ROOT',
|
||||||
|
CONFIG.get('static_root', None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if STATIC_ROOT is None:
|
||||||
|
print("ERROR: INVENTREE_STATIC_ROOT directory not defined")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# The filesystem location for served static files
|
||||||
|
MEDIA_ROOT = os.path.abspath(
|
||||||
|
get_setting(
|
||||||
|
'INVENTREE_MEDIA_ROOT',
|
||||||
|
CONFIG.get('media_root', None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if MEDIA_ROOT is None:
|
||||||
|
print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# List of allowed hosts (default = allow all)
|
# List of allowed hosts (default = allow all)
|
||||||
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
|
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
|
||||||
|
|
||||||
@@ -189,22 +213,12 @@ if cors_opt:
|
|||||||
# Web URL endpoint for served static files
|
# Web URL endpoint for served static files
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
# The filesystem location for served static files
|
STATICFILES_DIRS = []
|
||||||
STATIC_ROOT = os.path.abspath(
|
|
||||||
get_setting(
|
|
||||||
'INVENTREE_STATIC_ROOT',
|
|
||||||
CONFIG.get('static_root', '/home/inventree/data/static')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
STATICFILES_DIRS = [
|
|
||||||
os.path.join(BASE_DIR, 'InvenTree', 'static'),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Translated Template settings
|
# Translated Template settings
|
||||||
STATICFILES_I18_PREFIX = 'i18n'
|
STATICFILES_I18_PREFIX = 'i18n'
|
||||||
STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js', 'translated')
|
STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js', 'translated')
|
||||||
STATICFILES_I18_TRG = STATICFILES_DIRS[0] + '_' + STATICFILES_I18_PREFIX
|
STATICFILES_I18_TRG = os.path.join(BASE_DIR, 'InvenTree', 'static_i18n')
|
||||||
STATICFILES_DIRS.append(STATICFILES_I18_TRG)
|
STATICFILES_DIRS.append(STATICFILES_I18_TRG)
|
||||||
STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX)
|
STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX)
|
||||||
|
|
||||||
@@ -218,19 +232,11 @@ STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes')
|
|||||||
# Web URL endpoint for served media files
|
# Web URL endpoint for served media files
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
|
|
||||||
# The filesystem location for served static files
|
|
||||||
MEDIA_ROOT = os.path.abspath(
|
|
||||||
get_setting(
|
|
||||||
'INVENTREE_MEDIA_ROOT',
|
|
||||||
CONFIG.get('media_root', '/home/inventree/data/media')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
logger.info("InvenTree running in DEBUG mode")
|
logger.info("InvenTree running in DEBUG mode")
|
||||||
|
|
||||||
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||||
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
@@ -320,6 +326,7 @@ TEMPLATES = [
|
|||||||
'django.template.context_processors.i18n',
|
'django.template.context_processors.i18n',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
# Custom InvenTree context processors
|
||||||
'InvenTree.context.health_status',
|
'InvenTree.context.health_status',
|
||||||
'InvenTree.context.status_codes',
|
'InvenTree.context.status_codes',
|
||||||
'InvenTree.context.user_roles',
|
'InvenTree.context.user_roles',
|
||||||
@@ -413,7 +420,7 @@ Configure the database backend based on the user-specified values.
|
|||||||
- The following code lets the user "mix and match" database configuration
|
- The following code lets the user "mix and match" database configuration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
logger.info("Configuring database backend:")
|
logger.debug("Configuring database backend:")
|
||||||
|
|
||||||
# Extract database configuration from the config.yaml file
|
# Extract database configuration from the config.yaml file
|
||||||
db_config = CONFIG.get('database', {})
|
db_config = CONFIG.get('database', {})
|
||||||
@@ -467,11 +474,9 @@ if db_engine in ['sqlite3', 'postgresql', 'mysql']:
|
|||||||
db_name = db_config['NAME']
|
db_name = db_config['NAME']
|
||||||
db_host = db_config.get('HOST', "''")
|
db_host = db_config.get('HOST', "''")
|
||||||
|
|
||||||
print("InvenTree Database Configuration")
|
logger.info(f"DB_ENGINE: {db_engine}")
|
||||||
print("================================")
|
logger.info(f"DB_NAME: {db_name}")
|
||||||
print(f"ENGINE: {db_engine}")
|
logger.info(f"DB_HOST: {db_host}")
|
||||||
print(f"NAME: {db_name}")
|
|
||||||
print(f"HOST: {db_host}")
|
|
||||||
|
|
||||||
DATABASES['default'] = db_config
|
DATABASES['default'] = db_config
|
||||||
|
|
||||||
|
|||||||
@@ -640,6 +640,11 @@
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-error {
|
||||||
|
border: 2px #FCC solid;
|
||||||
|
background-color: #f5f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
border-bottom: 1px solid #ddd;
|
border-bottom: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
@@ -730,6 +735,13 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-panel {
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.modal input {
|
.modal input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -1037,6 +1049,11 @@ a.anchor {
|
|||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Force minimum width of number input fields to show at least ~5 digits */
|
||||||
|
input[type='number']{
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
.search-menu {
|
.search-menu {
|
||||||
padding-top: 2rem;
|
padding-top: 2rem;
|
||||||
}
|
}
|
||||||
@@ -1044,3 +1061,7 @@ a.anchor {
|
|||||||
.search-menu .ui-menu-item {
|
.search-menu .ui-menu-item {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-card-panel{
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def schedule_task(taskname, **kwargs):
|
|||||||
# If this task is already scheduled, don't schedule it again
|
# If this task is already scheduled, don't schedule it again
|
||||||
# Instead, update the scheduling parameters
|
# Instead, update the scheduling parameters
|
||||||
if Schedule.objects.filter(func=taskname).exists():
|
if Schedule.objects.filter(func=taskname).exists():
|
||||||
logger.info(f"Scheduled task '{taskname}' already exists - updating!")
|
logger.debug(f"Scheduled task '{taskname}' already exists - updating!")
|
||||||
|
|
||||||
Schedule.objects.filter(func=taskname).update(**kwargs)
|
Schedule.objects.filter(func=taskname).update(**kwargs)
|
||||||
else:
|
else:
|
||||||
@@ -204,6 +204,25 @@ def check_for_updates():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_expired_sessions():
|
||||||
|
"""
|
||||||
|
Remove any expired user sessions from the database
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from django.contrib.sessions.models import Session
|
||||||
|
|
||||||
|
# Delete any sessions that expired more than a day ago
|
||||||
|
expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1))
|
||||||
|
|
||||||
|
if True or expired.count() > 0:
|
||||||
|
logger.info(f"Deleting {expired.count()} expired sessions.")
|
||||||
|
expired.delete()
|
||||||
|
|
||||||
|
except AppRegistryNotReady:
|
||||||
|
logger.info("Could not perform 'delete_expired_sessions' - App registry not ready")
|
||||||
|
|
||||||
|
|
||||||
def update_exchange_rates():
|
def update_exchange_rates():
|
||||||
"""
|
"""
|
||||||
Update currency exchange rates
|
Update currency exchange rates
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ translated_javascript_urls = [
|
|||||||
url(r'^company.js', DynamicJsView.as_view(template_name='js/translated/company.js'), name='company.js'),
|
url(r'^company.js', DynamicJsView.as_view(template_name='js/translated/company.js'), name='company.js'),
|
||||||
url(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'),
|
url(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'),
|
||||||
url(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'),
|
url(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'),
|
||||||
|
url(r'^helpers.js', DynamicJsView.as_view(template_name='js/translated/helpers.js'), name='helpers.js'),
|
||||||
url(r'^label.js', DynamicJsView.as_view(template_name='js/translated/label.js'), name='label.js'),
|
url(r'^label.js', DynamicJsView.as_view(template_name='js/translated/label.js'), name='label.js'),
|
||||||
url(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), name='model_renderers.js'),
|
url(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), name='model_renderers.js'),
|
||||||
url(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
|
url(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
|
||||||
|
|||||||
@@ -8,36 +8,48 @@ import re
|
|||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
|
|
||||||
INVENTREE_SW_VERSION = "0.4.5"
|
INVENTREE_SW_VERSION = "0.5.0"
|
||||||
|
|
||||||
INVENTREE_API_VERSION = 9
|
INVENTREE_API_VERSION = 12
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
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
|
- Adds "price_string" to part pricing serializers
|
||||||
|
|
||||||
v8 -> 2021-07-19
|
v8 -> 2021-07-19
|
||||||
- Refactors the API interface for SupplierPart and ManufacturerPart models
|
- Refactors the API interface for SupplierPart and ManufacturerPart models
|
||||||
- ManufacturerPart objects can no longer be created via the SupplierPart API endpoint
|
- 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
|
- Introduced the concept of "API forms" in https://github.com/inventree/InvenTree/pull/1716
|
||||||
- API OPTIONS endpoints provide comprehensive field metedata
|
- API OPTIONS endpoints provide comprehensive field metedata
|
||||||
- Multiple new API endpoints added for database models
|
- 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
|
- 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
|
- 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
|
- BOM items can now accept "variant stock" to be assigned against them
|
||||||
- Many slight API tweaks were needed to get this to work properly!
|
- 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
|
- The updated StockItem "history tracking" now uses a different interface
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -58,7 +70,7 @@ def inventreeInstanceTitle():
|
|||||||
|
|
||||||
def inventreeVersion():
|
def inventreeVersion():
|
||||||
""" Returns the InvenTree version string """
|
""" Returns the InvenTree version string """
|
||||||
return INVENTREE_SW_VERSION
|
return INVENTREE_SW_VERSION.lower().strip()
|
||||||
|
|
||||||
|
|
||||||
def inventreeVersionTuple(version=None):
|
def inventreeVersionTuple(version=None):
|
||||||
@@ -72,6 +84,30 @@ def inventreeVersionTuple(version=None):
|
|||||||
return [int(g) for g in match.groups()]
|
return [int(g) for g in match.groups()]
|
||||||
|
|
||||||
|
|
||||||
|
def isInvenTreeDevelopmentVersion():
|
||||||
|
"""
|
||||||
|
Return True if current InvenTree version is a "development" version
|
||||||
|
"""
|
||||||
|
return inventreeVersion().endswith('dev')
|
||||||
|
|
||||||
|
|
||||||
|
def inventreeDocsVersion():
|
||||||
|
"""
|
||||||
|
Return the version string matching the latest documentation.
|
||||||
|
|
||||||
|
Development -> "latest"
|
||||||
|
Release -> "major.minor"
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isInvenTreeDevelopmentVersion():
|
||||||
|
return "latest"
|
||||||
|
else:
|
||||||
|
major, minor, patch = inventreeVersionTuple()
|
||||||
|
|
||||||
|
return f"{major}.{minor}"
|
||||||
|
|
||||||
|
|
||||||
def isInvenTreeUpToDate():
|
def isInvenTreeUpToDate():
|
||||||
"""
|
"""
|
||||||
Test if the InvenTree instance is "up to date" with the latest version.
|
Test if the InvenTree instance is "up to date" with the latest version.
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ from django.db.models import BooleanField
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializerField, UserSerializerBrief
|
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||||
|
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
|
||||||
|
|
||||||
from stock.serializers import StockItemSerializerBrief
|
from stock.serializers import StockItemSerializerBrief
|
||||||
from stock.serializers import LocationSerializer
|
from stock.serializers import LocationSerializer
|
||||||
@@ -158,7 +159,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class BuildAttachmentSerializer(InvenTreeModelSerializer):
|
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for a BuildAttachment
|
Serializer for a BuildAttachment
|
||||||
"""
|
"""
|
||||||
@@ -172,6 +173,7 @@ class BuildAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'build',
|
'build',
|
||||||
'attachment',
|
'attachment',
|
||||||
|
'filename',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
<b>{% trans "Automatically Allocate Stock" %}</b><br>
|
<strong>{% trans "Automatically Allocate Stock" %}</strong><br>
|
||||||
{% trans "The following stock items will be allocated to the specified build output" %}
|
{% trans "The following stock items will be allocated to the specified build output" %}
|
||||||
</div>
|
</div>
|
||||||
{% if allocations %}
|
{% if allocations %}
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ item.stock_item.part.full_name }}<br>
|
{{ item.stock_item.part.full_name }}<br>
|
||||||
<i>{{ item.stock_item.part.description }}</i>
|
<em>{{ item.stock_item.part.description }}</em>
|
||||||
</td>
|
</td>
|
||||||
<td>{% decimal item.quantity %}</td>
|
<td>{% decimal item.quantity %}</td>
|
||||||
<td>{{ item.stock_item.location }}</td>
|
<td>{{ item.stock_item.location }}</td>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class='alert alert-block alert-danger'>
|
<div class='alert alert-block alert-danger'>
|
||||||
<b>{% trans "Build Order is incomplete" %}</b><br>
|
<strong>{% trans "Build Order is incomplete" %}</strong><br>
|
||||||
<ul>
|
<ul>
|
||||||
{% if build.incomplete_count > 0 %}
|
{% if build.incomplete_count > 0 %}
|
||||||
<li>{% trans "Incompleted build outputs remain" %}</li>
|
<li>{% trans "Incompleted build outputs remain" %}</li>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{% if output %}
|
{% if output %}
|
||||||
<p>
|
<p>
|
||||||
{% blocktrans %}The allocated stock will be installed into the following build output:<br><i>{{output}}</i>{% endblocktrans %}
|
{% blocktrans %}The allocated stock will be installed into the following build output:<br><em>{{output}}</em>{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
{% if build.take_from %}
|
{% if build.take_from %}
|
||||||
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>{% include "clip.html"%}
|
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>{% include "clip.html"%}
|
||||||
{% else %}
|
{% else %}
|
||||||
<i>{% trans "Stock can be taken from any available location." %}</i>
|
<em>{% trans "Stock can be taken from any available location." %}</em>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
{{ build.destination }}
|
{{ build.destination }}
|
||||||
</a>{% include "clip.html"%}
|
</a>{% include "clip.html"%}
|
||||||
{% else %}
|
{% else %}
|
||||||
<i>{% trans "Destination location not specified" %}</i>
|
<em>{% trans "Destination location not specified" %}</em>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
{{ build.target_date }}{% if build.is_overdue %} <span class='fas fa-calendar-times icon-red'></span>{% endif %}
|
{{ build.target_date }}{% if build.is_overdue %} <span class='fas fa-calendar-times icon-red'></span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td><i>{% trans "No target date set" %}</i></td>
|
<td><em>{% trans "No target date set" %}</em></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
{% if build.completion_date %}
|
{% if build.completion_date %}
|
||||||
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td>
|
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td><i>{% trans "Build not complete" %}</i></td>
|
<td><em>{% trans "Build not complete" %}</em></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
<b>{% trans "Create a new build output" %}</b><br>
|
<strong>{% trans "Create a new build output" %}</strong><br>
|
||||||
{% trans "No incomplete build outputs remain." %}<br>
|
{% trans "No incomplete build outputs remain." %}<br>
|
||||||
{% trans "Create a new build output using the button above" %}
|
{% trans "Create a new build output using the button above" %}
|
||||||
</div>
|
</div>
|
||||||
@@ -369,6 +369,7 @@ loadAttachmentTable(
|
|||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: {
|
fields: {
|
||||||
|
filename: {},
|
||||||
comment: {},
|
comment: {},
|
||||||
},
|
},
|
||||||
onSuccess: reloadAttachmentTable,
|
onSuccess: reloadAttachmentTable,
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from django.db.utils import IntegrityError
|
|||||||
from InvenTree import status_codes as status
|
from InvenTree import status_codes as status
|
||||||
|
|
||||||
from build.models import Build, BuildItem, get_next_build_number
|
from build.models import Build, BuildItem, get_next_build_number
|
||||||
from stock.models import StockItem
|
|
||||||
from part.models import Part, BomItem
|
from part.models import Part, BomItem
|
||||||
|
from stock.models import StockItem
|
||||||
|
from stock.tasks import delete_old_stock_items
|
||||||
|
|
||||||
|
|
||||||
class BuildTest(TestCase):
|
class BuildTest(TestCase):
|
||||||
@@ -352,6 +353,11 @@ class BuildTest(TestCase):
|
|||||||
# the original BuildItem objects should have been deleted!
|
# the original BuildItem objects should have been deleted!
|
||||||
self.assertEqual(BuildItem.objects.count(), 0)
|
self.assertEqual(BuildItem.objects.count(), 0)
|
||||||
|
|
||||||
|
self.assertEqual(StockItem.objects.count(), 8)
|
||||||
|
|
||||||
|
# Clean up old stock items
|
||||||
|
delete_old_stock_items()
|
||||||
|
|
||||||
# New stock items should have been created!
|
# New stock items should have been created!
|
||||||
self.assertEqual(StockItem.objects.count(), 7)
|
self.assertEqual(StockItem.objects.count(), 7)
|
||||||
|
|
||||||
|
|||||||
+56
-32
@@ -20,13 +20,17 @@ from djmoney.contrib.exchange.models import convert_money
|
|||||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.utils.html import format_html
|
|
||||||
from django.core.validators import MinValueValidator, URLValidator
|
from django.core.validators import MinValueValidator, URLValidator
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.fields
|
import InvenTree.fields
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class BaseInvenTreeSetting(models.Model):
|
class BaseInvenTreeSetting(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -49,55 +53,37 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
are assigned their default values
|
are assigned their default values
|
||||||
"""
|
"""
|
||||||
|
|
||||||
keys = set()
|
|
||||||
settings = []
|
|
||||||
|
|
||||||
results = cls.objects.all()
|
results = cls.objects.all()
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
results = results.filter(user=user)
|
results = results.filter(user=user)
|
||||||
|
|
||||||
# Query the database
|
# Query the database
|
||||||
|
settings = {}
|
||||||
|
|
||||||
for setting in results:
|
for setting in results:
|
||||||
if setting.key:
|
if setting.key:
|
||||||
settings.append({
|
settings[setting.key.upper()] = setting.value
|
||||||
"key": setting.key.upper(),
|
|
||||||
"value": setting.value
|
|
||||||
})
|
|
||||||
|
|
||||||
keys.add(setting.key.upper())
|
|
||||||
|
|
||||||
# Specify any "default" values which are not in the database
|
# Specify any "default" values which are not in the database
|
||||||
for key in cls.GLOBAL_SETTINGS.keys():
|
for key in cls.GLOBAL_SETTINGS.keys():
|
||||||
|
|
||||||
if key.upper() not in keys:
|
if key.upper() not in settings:
|
||||||
|
|
||||||
settings.append({
|
settings[key.upper()] = cls.get_setting_default(key)
|
||||||
"key": key.upper(),
|
|
||||||
"value": cls.get_setting_default(key)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Enforce javascript formatting
|
|
||||||
for idx, setting in enumerate(settings):
|
|
||||||
|
|
||||||
key = setting['key']
|
|
||||||
value = setting['value']
|
|
||||||
|
|
||||||
|
for key, value in settings.items():
|
||||||
validator = cls.get_setting_validator(key)
|
validator = cls.get_setting_validator(key)
|
||||||
|
|
||||||
# Convert to javascript compatible booleans
|
|
||||||
if cls.validator_is_bool(validator):
|
if cls.validator_is_bool(validator):
|
||||||
value = str(value).lower()
|
value = InvenTree.helpers.str2bool(value)
|
||||||
|
|
||||||
# Numerical values remain the same
|
|
||||||
elif cls.validator_is_int(validator):
|
elif cls.validator_is_int(validator):
|
||||||
pass
|
try:
|
||||||
|
value = int(value)
|
||||||
|
except ValueError:
|
||||||
|
value = cls.get_setting_default(key)
|
||||||
|
|
||||||
# Wrap strings with quotes
|
settings[key] = value
|
||||||
else:
|
|
||||||
value = format_html("'{}'", value)
|
|
||||||
|
|
||||||
setting["value"] = value
|
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@@ -802,6 +788,44 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'description': _('Prefix value for purchase order reference'),
|
'description': _('Prefix value for purchase order reference'),
|
||||||
'default': 'PO',
|
'default': 'PO',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# enable/diable ui elements
|
||||||
|
'BUILD_FUNCTION_ENABLE': {
|
||||||
|
'name': _('Enable build'),
|
||||||
|
'description': _('Enable build functionality in InvenTree interface'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
'BUY_FUNCTION_ENABLE': {
|
||||||
|
'name': _('Enable buy'),
|
||||||
|
'description': _('Enable buy functionality in InvenTree interface'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
'SELL_FUNCTION_ENABLE': {
|
||||||
|
'name': _('Enable sell'),
|
||||||
|
'description': _('Enable sell functionality in InvenTree interface'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
'STOCK_FUNCTION_ENABLE': {
|
||||||
|
'name': _('Enable stock'),
|
||||||
|
'description': _('Enable stock functionality in InvenTree interface'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
'SO_FUNCTION_ENABLE': {
|
||||||
|
'name': _('Enable SO'),
|
||||||
|
'description': _('Enable SO functionality in InvenTree interface'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
'PO_FUNCTION_ENABLE': {
|
||||||
|
'name': _('Enable PO'),
|
||||||
|
'description': _('Enable PO functionality in InvenTree interface'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -1021,7 +1045,7 @@ class PriceBreak(models.Model):
|
|||||||
try:
|
try:
|
||||||
converted = convert_money(self.price, currency_code)
|
converted = convert_money(self.price, currency_code)
|
||||||
except MissingRate:
|
except MissingRate:
|
||||||
print(f"WARNING: No currency conversion rate available for {self.price_currency} -> {currency_code}")
|
logger.warning(f"No currency conversion rate available for {self.price_currency} -> {currency_code}")
|
||||||
return self.price.amount
|
return self.price.amount
|
||||||
|
|
||||||
return converted.amount
|
return converted.amount
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
<!--
|
<!--
|
||||||
<p>
|
<p>
|
||||||
<b>{{ name }}</b><br>
|
<strong>{{ name }}</strong><br>
|
||||||
{{ description }}<br>
|
{{ description }}<br>
|
||||||
<i>{% trans "Current value" %}: {{ value }}</i>
|
<em>{% trans "Current value" %}: {{ value }}</em>
|
||||||
</p>
|
</p>
|
||||||
-->
|
-->
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -501,6 +501,34 @@ class SupplierPart(models.Model):
|
|||||||
'manufacturer_part': _("Linked manufacturer part must reference the same base part"),
|
'manufacturer_part': _("Linked manufacturer part must reference the same base part"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
""" Overriding save method to connect an existing ManufacturerPart """
|
||||||
|
|
||||||
|
manufacturer_part = None
|
||||||
|
|
||||||
|
if all(key in kwargs for key in ('manufacturer', 'MPN')):
|
||||||
|
manufacturer_name = kwargs.pop('manufacturer')
|
||||||
|
MPN = kwargs.pop('MPN')
|
||||||
|
|
||||||
|
# Retrieve manufacturer part
|
||||||
|
try:
|
||||||
|
manufacturer_part = ManufacturerPart.objects.get(manufacturer__name=manufacturer_name, MPN=MPN)
|
||||||
|
except (ValueError, Company.DoesNotExist):
|
||||||
|
# ManufacturerPart does not exist
|
||||||
|
pass
|
||||||
|
|
||||||
|
if manufacturer_part:
|
||||||
|
if not self.manufacturer_part:
|
||||||
|
# Connect ManufacturerPart to SupplierPart
|
||||||
|
self.manufacturer_part = manufacturer_part
|
||||||
|
else:
|
||||||
|
raise ValidationError(f'SupplierPart {self.__str__} is already linked to {self.manufacturer_part}')
|
||||||
|
|
||||||
|
self.clean()
|
||||||
|
self.validate_unique()
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
|
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
|
||||||
related_name='supplier_parts',
|
related_name='supplier_parts',
|
||||||
verbose_name=_('Base Part'),
|
verbose_name=_('Base Part'),
|
||||||
|
|||||||
@@ -204,9 +204,9 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True))
|
supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True))
|
||||||
|
|
||||||
manufacturer = serializers.PrimaryKeyRelatedField(source='manufacturer_part.manufacturer', read_only=True)
|
manufacturer = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
MPN = serializers.StringRelatedField(source='manufacturer_part.MPN')
|
MPN = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True)
|
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True)
|
||||||
|
|
||||||
@@ -231,6 +231,25 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
'supplier_detail',
|
'supplier_detail',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
""" Extract manufacturer data and process ManufacturerPart """
|
||||||
|
|
||||||
|
# Create SupplierPart
|
||||||
|
supplier_part = super().create(validated_data)
|
||||||
|
|
||||||
|
# Get ManufacturerPart raw data (unvalidated)
|
||||||
|
manufacturer = self.initial_data.get('manufacturer', None)
|
||||||
|
MPN = self.initial_data.get('MPN', None)
|
||||||
|
|
||||||
|
if manufacturer and MPN:
|
||||||
|
kwargs = {
|
||||||
|
'manufacturer': manufacturer,
|
||||||
|
'MPN': MPN,
|
||||||
|
}
|
||||||
|
supplier_part.save(**kwargs)
|
||||||
|
|
||||||
|
return supplier_part
|
||||||
|
|
||||||
|
|
||||||
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for SupplierPriceBreak object """
|
""" Serializer for SupplierPriceBreak object """
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
{% if company.currency %}
|
{% if company.currency %}
|
||||||
{{ company.currency }}
|
{{ company.currency }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<i>{% trans "Uses default currency" %}</i>
|
<em>{% trans "Uses default currency" %}</em>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -24,19 +24,17 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<div class="dropdown" style="float: right;">
|
<button class="btn btn-primary dropdown-toggle" id='supplier-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
|
||||||
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}
|
<span class="caret"></span>
|
||||||
<span class="caret"></span>
|
</button>
|
||||||
</button>
|
<ul class="dropdown-menu">
|
||||||
<ul class="dropdown-menu">
|
{% if roles.purchase_order.add %}
|
||||||
{% if roles.purchase_order.add %}
|
<li><a href='#' id='multi-supplier-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||||
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
{% endif %}
|
||||||
{% endif %}
|
{% if roles.purchase_order.delete %}
|
||||||
{% if roles.purchase_order.delete %}
|
<li><a href='#' id='multi-supplier-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
||||||
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
{% endif %}
|
||||||
{% endif %}
|
</ul>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='filter-list' id='filter-list-supplier-part'>
|
<div class='filter-list' id='filter-list-supplier-part'>
|
||||||
@@ -59,27 +57,25 @@
|
|||||||
{% if roles.purchase_order.change %}
|
{% if roles.purchase_order.change %}
|
||||||
<div id='manufacturer-part-button-toolbar'>
|
<div id='manufacturer-part-button-toolbar'>
|
||||||
<div class='button-toolbar container-fluid'>
|
<div class='button-toolbar container-fluid'>
|
||||||
<div class='btn-group role='group'>
|
<div class='btn-group' role='group'>
|
||||||
{% if roles.purchase_order.add %}
|
{% if roles.purchase_order.add %}
|
||||||
<button class="btn btn-success" id='manufacturer-part-create' title='{% trans "Create new manufacturer part" %}'>
|
<button type="button" class="btn btn-success" id='manufacturer-part-create' title='{% trans "Create new manufacturer part" %}'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group' role='group'>
|
||||||
<div class="dropdown" style="float: right;">
|
<button class="btn btn-primary dropdown-toggle" id='manufacturer-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
|
||||||
<button class="btn btn-primary dropdown-toggle" id='table-options', type="button" data-toggle="dropdown">{% trans "Options" %}
|
<span class="caret"></span>
|
||||||
<span class="caret"></span>
|
</button>
|
||||||
</button>
|
<ul class="dropdown-menu">
|
||||||
<ul class="dropdown-menu">
|
{% if roles.purchase_order.add %}
|
||||||
{% if roles.purchase_order.add %}
|
<li><a href='#' id='multi-manufacturer-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||||
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
{% endif %}
|
||||||
{% endif %}
|
{% if roles.purchase_order.delete %}
|
||||||
{% if roles.purchase_order.delete %}
|
<li><a href='#' id='multi-manufacturer-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
||||||
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
{% endif %}
|
||||||
{% endif %}
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class='filter-list' id='filter-list-supplier-part'>
|
<div class='filter-list' id='filter-list-supplier-part'>
|
||||||
<!-- Empty div (will be filled out with available BOM filters) -->
|
<!-- Empty div (will be filled out with available BOM filters) -->
|
||||||
@@ -87,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<table class='table table-striped table-condensed' id='part-table' data-toolbar='#manufacturer-part-button-toolbar'>
|
<table class='table table-striped table-condensed' id='manufacturer-part-table' data-toolbar='#manufacturer-part-button-toolbar'>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,7 +105,7 @@
|
|||||||
{% if roles.purchase_order.add %}
|
{% if roles.purchase_order.add %}
|
||||||
<div id='po-button-bar'>
|
<div id='po-button-bar'>
|
||||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||||
<button class='btn btn-primary' type='button' id='company-order2' title='{% trans "Create new purchase order" %}'>
|
<button class='btn btn-success' type='button' id='company-order2' title='{% trans "Create new purchase order" %}'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}</button>
|
<span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}</button>
|
||||||
<div class='filter-list' id='filter-list-purchaseorder'>
|
<div class='filter-list' id='filter-list-purchaseorder'>
|
||||||
<!-- Empty div -->
|
<!-- Empty div -->
|
||||||
@@ -131,7 +127,7 @@
|
|||||||
{% if roles.sales_order.add %}
|
{% if roles.sales_order.add %}
|
||||||
<div id='so-button-bar'>
|
<div id='so-button-bar'>
|
||||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||||
<button class='btn btn-primary' type='button' id='new-sales-order' title='{% trans "Create new sales order" %}'>
|
<button class='btn btn-success' type='button' id='new-sales-order' title='{% trans "Create new sales order" %}'>
|
||||||
<div class='fas fa-plus-circle'></div> {% trans "New Sales Order" %}
|
<div class='fas fa-plus-circle'></div> {% trans "New Sales Order" %}
|
||||||
</button>
|
</button>
|
||||||
<div class='filter-list' id='filter-list-salesorder'>
|
<div class='filter-list' id='filter-list-salesorder'>
|
||||||
@@ -274,6 +270,10 @@
|
|||||||
|
|
||||||
{% if company.is_manufacturer %}
|
{% if company.is_manufacturer %}
|
||||||
|
|
||||||
|
function reloadManufacturerPartTable() {
|
||||||
|
$('#manufacturer-part-table').bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
|
||||||
$("#manufacturer-part-create").click(function () {
|
$("#manufacturer-part-create").click(function () {
|
||||||
|
|
||||||
createManufacturerPart({
|
createManufacturerPart({
|
||||||
@@ -285,7 +285,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
loadManufacturerPartTable(
|
loadManufacturerPartTable(
|
||||||
"#part-table",
|
"#manufacturer-part-table",
|
||||||
"{% url 'api-manufacturer-part-list' %}",
|
"{% url 'api-manufacturer-part-list' %}",
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
@@ -296,20 +296,20 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
linkButtonsToSelection($("#manufacturer-table"), ['#table-options']);
|
linkButtonsToSelection($("#manufacturer-part-table"), ['#manufacturer-table-options']);
|
||||||
|
|
||||||
$("#multi-part-delete").click(function() {
|
$("#multi-manufacturer-part-delete").click(function() {
|
||||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
|
||||||
|
|
||||||
deleteManufacturerParts(selections, {
|
deleteManufacturerParts(selections, {
|
||||||
onSuccess: function() {
|
onSuccess: function() {
|
||||||
$("#part-table").bootstrapTable("refresh");
|
$("#manufacturer-part-table").bootstrapTable("refresh");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#multi-part-order").click(function() {
|
$("#multi-manufacturer-part-order").click(function() {
|
||||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
|
||||||
|
|
||||||
var parts = [];
|
var parts = [];
|
||||||
|
|
||||||
@@ -353,9 +353,9 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
{% endif %}
|
linkButtonsToSelection($("#supplier-part-table"), ['#supplier-table-options']);
|
||||||
|
|
||||||
$("#multi-part-delete").click(function() {
|
$("#multi-supplier-part-delete").click(function() {
|
||||||
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
|
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
|
||||||
|
|
||||||
var requests = [];
|
var requests = [];
|
||||||
@@ -379,8 +379,8 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#multi-part-order").click(function() {
|
$("#multi-supplier-part-order").click(function() {
|
||||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
|
||||||
|
|
||||||
var parts = [];
|
var parts = [];
|
||||||
|
|
||||||
@@ -395,6 +395,8 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
attachNavCallbacks({
|
attachNavCallbacks({
|
||||||
name: 'company',
|
name: 'company',
|
||||||
default: 'company-stock'
|
default: 'company-stock'
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
||||||
</button>
|
</button>
|
||||||
<div id='opt-dropdown' class="btn-group">
|
<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">
|
<ul class="dropdown-menu">
|
||||||
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -133,7 +133,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
|
||||||
</button>
|
</button>
|
||||||
<div id='opt-dropdown' class="btn-group">
|
<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">
|
<ul class="dropdown-menu">
|
||||||
<li><a href='#' id='multi-parameter-delete' title='{% trans "Delete parameters" %}'>{% trans "Delete" %}</a></li>
|
<li><a href='#' id='multi-parameter-delete' title='{% trans "Delete parameters" %}'>{% trans "Delete" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -225,7 +225,7 @@ $("#multi-parameter-delete").click(function() {
|
|||||||
<ul>`;
|
<ul>`;
|
||||||
|
|
||||||
selections.forEach(function(item) {
|
selections.forEach(function(item) {
|
||||||
text += `<li>${item.name} - <i>${item.value}</i></li>`;
|
text += `<li>${item.name} - <em>${item.value}</em></li>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
text += `
|
text += `
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
|
||||||
|
{% settings_value 'SO_FUNCTION_ENABLE' as enable_so %}
|
||||||
|
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
|
||||||
|
|
||||||
<ul class='list-group'>
|
<ul class='list-group'>
|
||||||
<li class='list-group-item'>
|
<li class='list-group-item'>
|
||||||
<a href='#' id='company-menu-toggle'>
|
<a href='#' id='company-menu-toggle'>
|
||||||
@@ -28,6 +32,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if company.is_manufacturer or company.is_supplier %}
|
{% if company.is_manufacturer or company.is_supplier %}
|
||||||
|
{% if enable_stock %}
|
||||||
<li class='list-group-item' title='{% trans "Stock Items" %}'>
|
<li class='list-group-item' title='{% trans "Stock Items" %}'>
|
||||||
<a href='#' id='select-company-stock' class='nav-toggle'>
|
<a href='#' id='select-company-stock' class='nav-toggle'>
|
||||||
<span class='fas fa-boxes sidebar-icon'></span>
|
<span class='fas fa-boxes sidebar-icon'></span>
|
||||||
@@ -35,8 +40,9 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if company.is_supplier %}
|
{% if company.is_supplier and enable_po %}
|
||||||
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
|
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
|
||||||
<a href='#' id='select-purchase-orders' class='nav-toggle'>
|
<a href='#' id='select-purchase-orders' class='nav-toggle'>
|
||||||
<span class='fas fa-shopping-cart sidebar-icon'></span>
|
<span class='fas fa-shopping-cart sidebar-icon'></span>
|
||||||
@@ -45,7 +51,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if company.is_customer %}
|
{% if company.is_customer and enable_so %}
|
||||||
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
|
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
|
||||||
<a href='#' id='select-sales-orders' class='nav-toggle'>
|
<a href='#' id='select-sales-orders' class='nav-toggle'>
|
||||||
<span class='fas fa-truck sidebar-icon'></span>
|
<span class='fas fa-truck sidebar-icon'></span>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
{% if roles.purchase_order.add %}
|
{% if roles.purchase_order.add %}
|
||||||
<div id='price-break-toolbar' class='btn-group'>
|
<div id='price-break-toolbar' class='btn-group'>
|
||||||
<button class='btn btn-primary' id='new-price-break' type='button'>
|
<button class='btn btn-success' id='new-price-break' type='button'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "Add Price Break" %}
|
<span class='fas fa-plus-circle'></span> {% trans "Add Price Break" %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
+1638
-1456
File diff suppressed because it is too large
Load Diff
+1600
-1418
File diff suppressed because it is too large
Load Diff
+1574
-1395
File diff suppressed because it is too large
Load Diff
+1705
-1523
File diff suppressed because it is too large
Load Diff
+1588
-1406
File diff suppressed because it is too large
Load Diff
+1600
-1418
File diff suppressed because it is too large
Load Diff
+1600
-1418
File diff suppressed because it is too large
Load Diff
+1640
-1458
File diff suppressed because it is too large
Load Diff
+1589
-1407
File diff suppressed because it is too large
Load Diff
+1600
-1418
File diff suppressed because it is too large
Load Diff
+1795
-1613
File diff suppressed because it is too large
Load Diff
+1588
-1406
File diff suppressed because it is too large
Load Diff
+1588
-1406
File diff suppressed because it is too large
Load Diff
+1588
-1406
File diff suppressed because it is too large
Load Diff
+1656
-1474
File diff suppressed because it is too large
Load Diff
+1600
-1418
File diff suppressed because it is too large
Load Diff
+1592
-1410
File diff suppressed because it is too large
Load Diff
+1600
-1418
File diff suppressed because it is too large
Load Diff
+2379
-2197
File diff suppressed because it is too large
Load Diff
+165
-18
@@ -5,13 +5,18 @@ JSON API for the Order app
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters import rest_framework as rest_filters
|
||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
from rest_framework import filters, status
|
from rest_framework import filters, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
from InvenTree.filters import InvenTreeOrderingFilter
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
from InvenTree.api import AttachmentMixin
|
from InvenTree.api import AttachmentMixin
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||||
@@ -27,6 +32,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
|
|||||||
from .models import SalesOrderAttachment
|
from .models import SalesOrderAttachment
|
||||||
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
||||||
from .serializers import SalesOrderAllocationSerializer
|
from .serializers import SalesOrderAllocationSerializer
|
||||||
|
from .serializers import POReceiveSerializer
|
||||||
|
|
||||||
|
|
||||||
class POList(generics.ListCreateAPIView):
|
class POList(generics.ListCreateAPIView):
|
||||||
@@ -144,7 +150,7 @@ class POList(generics.ListCreateAPIView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
rest_filters.DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
filters.OrderingFilter,
|
filters.OrderingFilter,
|
||||||
]
|
]
|
||||||
@@ -204,6 +210,111 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class POReceive(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint to receive stock items against a purchase order.
|
||||||
|
|
||||||
|
- The purchase order is specified in the URL.
|
||||||
|
- Items to receive are specified as a list called "items" with the following options:
|
||||||
|
- supplier_part: pk value of the supplier part
|
||||||
|
- quantity: quantity to receive
|
||||||
|
- status: stock item status
|
||||||
|
- location: destination for stock item (optional)
|
||||||
|
- A global location can also be specified
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = PurchaseOrderLineItem.objects.none()
|
||||||
|
|
||||||
|
serializer_class = POReceiveSerializer
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
|
||||||
|
context = super().get_serializer_context()
|
||||||
|
|
||||||
|
# Pass the purchase order through to the serializer for validation
|
||||||
|
context['order'] = self.get_order()
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_order(self):
|
||||||
|
"""
|
||||||
|
Returns the PurchaseOrder associated with this API endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
pk = self.kwargs.get('pk', None)
|
||||||
|
|
||||||
|
if pk is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
order = PurchaseOrder.objects.get(pk=self.kwargs['pk'])
|
||||||
|
return order
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
|
||||||
|
# Which purchase order are we receiving against?
|
||||||
|
self.order = self.get_order()
|
||||||
|
|
||||||
|
# Validate the serialized data
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# Receive the line items
|
||||||
|
self.receive_items(serializer)
|
||||||
|
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def receive_items(self, serializer):
|
||||||
|
"""
|
||||||
|
Receive the items
|
||||||
|
|
||||||
|
At this point, much of the heavy lifting has been done for us by DRF serializers!
|
||||||
|
|
||||||
|
We have a list of "items", each a dict which contains:
|
||||||
|
- line_item: A PurchaseOrderLineItem matching this order
|
||||||
|
- location: A destination location
|
||||||
|
- quantity: A validated numerical quantity
|
||||||
|
- status: The status code for the received item
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = serializer.validated_data
|
||||||
|
|
||||||
|
location = data['location']
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
|
||||||
|
# Check if the location is not specified for any particular item
|
||||||
|
for item in items:
|
||||||
|
|
||||||
|
line = item['line_item']
|
||||||
|
|
||||||
|
if not item.get('location', None):
|
||||||
|
# If a global location is specified, use that
|
||||||
|
item['location'] = location
|
||||||
|
|
||||||
|
if not item['location']:
|
||||||
|
# The line item specifies a location?
|
||||||
|
item['location'] = line.get_destination()
|
||||||
|
|
||||||
|
if not item['location']:
|
||||||
|
raise ValidationError({
|
||||||
|
'location': _("Destination location must be specified"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Now we can actually receive the items
|
||||||
|
for item in items:
|
||||||
|
|
||||||
|
self.order.receive_line_item(
|
||||||
|
item['line_item'],
|
||||||
|
item['location'],
|
||||||
|
item['quantity'],
|
||||||
|
self.request.user,
|
||||||
|
status=item['status'],
|
||||||
|
barcode=item.get('barcode', ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class POLineItemList(generics.ListCreateAPIView):
|
class POLineItemList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of POLineItem objects
|
""" API endpoint for accessing a list of POLineItem objects
|
||||||
|
|
||||||
@@ -214,6 +325,14 @@ class POLineItemList(generics.ListCreateAPIView):
|
|||||||
queryset = PurchaseOrderLineItem.objects.all()
|
queryset = PurchaseOrderLineItem.objects.all()
|
||||||
serializer_class = POLineItemSerializer
|
serializer_class = POLineItemSerializer
|
||||||
|
|
||||||
|
def get_queryset(self, *args, **kwargs):
|
||||||
|
|
||||||
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
|
|
||||||
|
queryset = POLineItemSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -226,18 +345,26 @@ class POLineItemList(generics.ListCreateAPIView):
|
|||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
rest_filters.DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
filters.OrderingFilter
|
InvenTreeOrderingFilter
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ordering_field_aliases = {
|
||||||
|
'MPN': 'part__manufacturer_part__MPN',
|
||||||
|
'SKU': 'part__SKU',
|
||||||
|
'part_name': 'part__part__name',
|
||||||
|
}
|
||||||
|
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
'part__part__name',
|
'MPN',
|
||||||
'part__MPN',
|
'part_name',
|
||||||
'part__SKU',
|
'purchase_price',
|
||||||
'reference',
|
|
||||||
'quantity',
|
'quantity',
|
||||||
'received',
|
'received',
|
||||||
|
'reference',
|
||||||
|
'SKU',
|
||||||
|
'total_price',
|
||||||
]
|
]
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
@@ -262,6 +389,14 @@ class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
queryset = PurchaseOrderLineItem.objects.all()
|
queryset = PurchaseOrderLineItem.objects.all()
|
||||||
serializer_class = POLineItemSerializer
|
serializer_class = POLineItemSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
|
queryset = POLineItemSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||||
"""
|
"""
|
||||||
@@ -272,7 +407,7 @@ class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
serializer_class = SOAttachmentSerializer
|
serializer_class = SOAttachmentSerializer
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
rest_filters.DjangoFilterBackend,
|
||||||
]
|
]
|
||||||
|
|
||||||
filter_fields = [
|
filter_fields = [
|
||||||
@@ -396,7 +531,7 @@ class SOList(generics.ListCreateAPIView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
rest_filters.DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
filters.OrderingFilter,
|
filters.OrderingFilter,
|
||||||
]
|
]
|
||||||
@@ -495,7 +630,7 @@ class SOLineItemList(generics.ListCreateAPIView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
rest_filters.DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
filters.OrderingFilter
|
filters.OrderingFilter
|
||||||
]
|
]
|
||||||
@@ -580,7 +715,7 @@ class SOAllocationList(generics.ListCreateAPIView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
rest_filters.DjangoFilterBackend,
|
||||||
]
|
]
|
||||||
|
|
||||||
# Default filterable fields
|
# Default filterable fields
|
||||||
@@ -598,7 +733,7 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
serializer_class = POAttachmentSerializer
|
serializer_class = POAttachmentSerializer
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
rest_filters.DjangoFilterBackend,
|
||||||
]
|
]
|
||||||
|
|
||||||
filter_fields = [
|
filter_fields = [
|
||||||
@@ -616,13 +751,25 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
|
|||||||
|
|
||||||
|
|
||||||
order_api_urls = [
|
order_api_urls = [
|
||||||
|
|
||||||
# API endpoints for purchase orders
|
# API endpoints for purchase orders
|
||||||
url(r'po/attachment/', include([
|
url(r'^po/', include([
|
||||||
url(r'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'),
|
|
||||||
url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'),
|
# 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
|
# API endpoints for purchase order line items
|
||||||
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
|
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
|
||||||
|
|||||||
@@ -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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -411,6 +411,11 @@ class PurchaseOrder(Order):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
notes = kwargs.get('notes', '')
|
notes = kwargs.get('notes', '')
|
||||||
|
barcode = kwargs.get('barcode', '')
|
||||||
|
|
||||||
|
# Prevent null values for barcode
|
||||||
|
if barcode is None:
|
||||||
|
barcode = ''
|
||||||
|
|
||||||
if not self.status == PurchaseOrderStatus.PLACED:
|
if not self.status == PurchaseOrderStatus.PLACED:
|
||||||
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
|
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
|
||||||
@@ -433,7 +438,8 @@ class PurchaseOrder(Order):
|
|||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
purchase_order=self,
|
purchase_order=self,
|
||||||
status=status,
|
status=status,
|
||||||
purchase_price=purchase_price,
|
purchase_price=line.purchase_price,
|
||||||
|
uid=barcode
|
||||||
)
|
)
|
||||||
|
|
||||||
stock.save(add_note=False)
|
stock.save(add_note=False)
|
||||||
@@ -729,7 +735,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (
|
unique_together = (
|
||||||
('order', 'part')
|
('order', 'part', 'quantity', 'purchase_price')
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -767,7 +773,13 @@ class PurchaseOrderLineItem(OrderLineItem):
|
|||||||
help_text=_("Supplier part"),
|
help_text=_("Supplier part"),
|
||||||
)
|
)
|
||||||
|
|
||||||
received = models.DecimalField(decimal_places=5, max_digits=15, default=0, verbose_name=_('Received'), help_text=_('Number of items received'))
|
received = models.DecimalField(
|
||||||
|
decimal_places=5,
|
||||||
|
max_digits=15,
|
||||||
|
default=0,
|
||||||
|
verbose_name=_('Received'),
|
||||||
|
help_text=_('Number of items received')
|
||||||
|
)
|
||||||
|
|
||||||
purchase_price = InvenTreeModelMoneyField(
|
purchase_price = InvenTreeModelMoneyField(
|
||||||
max_digits=19,
|
max_digits=19,
|
||||||
@@ -778,7 +790,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
|||||||
)
|
)
|
||||||
|
|
||||||
destination = TreeForeignKey(
|
destination = TreeForeignKey(
|
||||||
'stock.StockLocation', on_delete=models.DO_NOTHING,
|
'stock.StockLocation', on_delete=models.SET_NULL,
|
||||||
verbose_name=_('Destination'),
|
verbose_name=_('Destination'),
|
||||||
related_name='po_lines',
|
related_name='po_lines',
|
||||||
blank=True, null=True,
|
blank=True, null=True,
|
||||||
|
|||||||
@@ -7,18 +7,27 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
from django.db.models import Case, When, Value
|
from django.db.models import Case, When, Value
|
||||||
from django.db.models import BooleanField
|
from django.db.models import BooleanField, ExpressionWrapper, F
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
from sql_util.utils import SubqueryCount
|
from sql_util.utils import SubqueryCount
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
|
from InvenTree.serializers import InvenTreeAttachmentSerializer
|
||||||
from InvenTree.serializers import InvenTreeMoneySerializer
|
from InvenTree.serializers import InvenTreeMoneySerializer
|
||||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||||
|
|
||||||
|
from InvenTree.status_codes import StockStatus
|
||||||
|
|
||||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||||
|
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
|
import stock.models
|
||||||
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
||||||
|
|
||||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||||
@@ -108,6 +117,23 @@ class POSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
class POLineItemSerializer(InvenTreeModelSerializer):
|
class POLineItemSerializer(InvenTreeModelSerializer):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def annotate_queryset(queryset):
|
||||||
|
"""
|
||||||
|
Add some extra annotations to this queryset:
|
||||||
|
|
||||||
|
- Total price = purchase_price * quantity
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
total_price=ExpressionWrapper(
|
||||||
|
F('purchase_price') * F('quantity'),
|
||||||
|
output_field=models.DecimalField()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
part_detail = kwargs.pop('part_detail', False)
|
part_detail = kwargs.pop('part_detail', False)
|
||||||
@@ -118,10 +144,11 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
self.fields.pop('part_detail')
|
self.fields.pop('part_detail')
|
||||||
self.fields.pop('supplier_part_detail')
|
self.fields.pop('supplier_part_detail')
|
||||||
|
|
||||||
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
|
|
||||||
quantity = serializers.FloatField(default=1)
|
quantity = serializers.FloatField(default=1)
|
||||||
received = serializers.FloatField(default=0)
|
received = serializers.FloatField(default=0)
|
||||||
|
|
||||||
|
total_price = serializers.FloatField(read_only=True)
|
||||||
|
|
||||||
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
||||||
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
|
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
|
||||||
|
|
||||||
@@ -157,10 +184,136 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
'purchase_price_string',
|
'purchase_price_string',
|
||||||
'destination',
|
'destination',
|
||||||
'destination_detail',
|
'destination_detail',
|
||||||
|
'total_price',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class POAttachmentSerializer(InvenTreeModelSerializer):
|
class POLineItemReceiveSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
A serializer for receiving a single purchase order line item against a purchase order
|
||||||
|
"""
|
||||||
|
|
||||||
|
line_item = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=PurchaseOrderLineItem.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=False,
|
||||||
|
required=True,
|
||||||
|
label=_('Line Item'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_line_item(self, item):
|
||||||
|
|
||||||
|
if item.order != self.context['order']:
|
||||||
|
raise ValidationError(_('Line item does not match purchase order'))
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
location = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=stock.models.StockLocation.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=True,
|
||||||
|
required=False,
|
||||||
|
label=_('Location'),
|
||||||
|
help_text=_('Select destination location for received items'),
|
||||||
|
)
|
||||||
|
|
||||||
|
quantity = serializers.DecimalField(
|
||||||
|
max_digits=15,
|
||||||
|
decimal_places=5,
|
||||||
|
min_value=0,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
status = serializers.ChoiceField(
|
||||||
|
choices=list(StockStatus.items()),
|
||||||
|
default=StockStatus.OK,
|
||||||
|
label=_('Status'),
|
||||||
|
)
|
||||||
|
|
||||||
|
barcode = serializers.CharField(
|
||||||
|
label=_('Barcode Hash'),
|
||||||
|
help_text=_('Unique identifier field'),
|
||||||
|
default='',
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_barcode(self, barcode):
|
||||||
|
"""
|
||||||
|
Cannot check in a LineItem with a barcode that is already assigned
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Ignore empty barcode values
|
||||||
|
if not barcode or barcode.strip() == '':
|
||||||
|
return
|
||||||
|
|
||||||
|
if stock.models.StockItem.objects.filter(uid=barcode).exists():
|
||||||
|
raise ValidationError(_('Barcode is already in use'))
|
||||||
|
|
||||||
|
return barcode
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'barcode',
|
||||||
|
'line_item',
|
||||||
|
'location',
|
||||||
|
'quantity',
|
||||||
|
'status',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class POReceiveSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for receiving items against a purchase order
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = POLineItemReceiveSerializer(many=True)
|
||||||
|
|
||||||
|
location = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=stock.models.StockLocation.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=True,
|
||||||
|
label=_('Location'),
|
||||||
|
help_text=_('Select destination location for received items'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_valid(self, raise_exception=False):
|
||||||
|
|
||||||
|
super().is_valid(raise_exception)
|
||||||
|
|
||||||
|
# Custom validation
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
items = data.get('items', [])
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
self._errors['items'] = _('Line items must be provided')
|
||||||
|
else:
|
||||||
|
# Ensure barcodes are unique
|
||||||
|
unique_barcodes = set()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
barcode = item.get('barcode', '')
|
||||||
|
|
||||||
|
if barcode:
|
||||||
|
if barcode in unique_barcodes:
|
||||||
|
self._errors['items'] = _('Supplied barcode values must be unique')
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
unique_barcodes.add(barcode)
|
||||||
|
|
||||||
|
if self._errors and raise_exception:
|
||||||
|
raise ValidationError(self.errors)
|
||||||
|
|
||||||
|
return not bool(self._errors)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'items',
|
||||||
|
'location',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class POAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""
|
||||||
Serializers for the PurchaseOrderAttachment model
|
Serializers for the PurchaseOrderAttachment model
|
||||||
"""
|
"""
|
||||||
@@ -174,6 +327,7 @@ class POAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'order',
|
'order',
|
||||||
'attachment',
|
'attachment',
|
||||||
|
'filename',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
]
|
]
|
||||||
@@ -381,7 +535,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class SOAttachmentSerializer(InvenTreeModelSerializer):
|
class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""
|
||||||
Serializers for the SalesOrderAttachment model
|
Serializers for the SalesOrderAttachment model
|
||||||
"""
|
"""
|
||||||
@@ -395,6 +549,7 @@ class SOAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'order',
|
'order',
|
||||||
'attachment',
|
'attachment',
|
||||||
|
'filename',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
{% for duplicate in duplicates %}
|
{% for duplicate in duplicates %}
|
||||||
{% if duplicate == col.value %}
|
{% if duplicate == col.value %}
|
||||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||||
<b>{% trans "Duplicate selection" %}</b>
|
<strong>{% trans "Duplicate selection" %}</strong>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
$('.bomselect').select2({
|
$('.bomselect').select2({
|
||||||
dropdownAutoWidth: true,
|
width: '100%',
|
||||||
matcher: partialMatcher,
|
matcher: partialMatcher,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<tr id='part_row_{{ part.id }}'>
|
<tr id='part_row_{{ part.id }}'>
|
||||||
<td>
|
<td>
|
||||||
{% include "hover_image.html" with image=part.image hover=False %}
|
{% include "hover_image.html" with image=part.image hover=False %}
|
||||||
{{ part.full_name }} <small><i>{{ part.description }}</i></small>
|
{{ part.full_name }} <small><em>{{ part.description }}</em></small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class='btn btn-default btn-create' onClick='newSupplierPartFromOrderWizard()' id='new_supplier_part_{{ part.id }}' part='{{ part.pk }}' title='{% trans "Create new supplier part" %}' type='button'>
|
<button class='btn btn-default btn-create' onClick='newSupplierPartFromOrderWizard()' id='new_supplier_part_{{ part.id }}' part='{{ part.pk }}' title='{% trans "Create new supplier part" %}' type='button'>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{% if not part.order_supplier %}
|
{% if not part.order_supplier %}
|
||||||
<span class='help-inline'>{% blocktrans with name=part.name %}Select a supplier for <i>{{name}}</i>{% endblocktrans %}</span>
|
<span class='help-inline'>{% blocktrans with name=part.name %}Select a supplier for <em>{{name}}</em>{% endblocktrans %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
|
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
|
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
|
||||||
<button type='button' class='btn btn-primary' id='new-po-line'>
|
<button type='button' class='btn btn-success' id='new-po-line'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
|
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
|
||||||
</button>
|
</button>
|
||||||
<a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'>
|
<a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class='table table-striped table-condensed' id='po-table' data-toolbar='#order-toolbar-buttons'>
|
<table class='table table-striped table-condensed' id='po-line-table' data-toolbar='#order-toolbar-buttons'>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<h4>{% trans "Received Items" %}</h4>
|
<h4>{% trans "Received Items" %}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
{% include "stock_table.html" with read_only=True %}
|
{% include "stock_table.html" with prevent_new_stock=True %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,6 +122,7 @@
|
|||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: {
|
fields: {
|
||||||
|
filename: {},
|
||||||
comment: {},
|
comment: {},
|
||||||
},
|
},
|
||||||
onSuccess: reloadAttachmentTable,
|
onSuccess: reloadAttachmentTable,
|
||||||
@@ -200,255 +201,32 @@ $('#new-po-line').click(function() {
|
|||||||
},
|
},
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
title: '{% trans "Add Line Item" %}',
|
title: '{% trans "Add Line Item" %}',
|
||||||
onSuccess: reloadTable,
|
onSuccess: function() {
|
||||||
|
$('#po-line-table').bootstrapTable('refresh');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
function reloadTable() {
|
loadPurchaseOrderLineItemTable('#po-line-table', {
|
||||||
$("#po-table").bootstrapTable("refresh");
|
order: {{ order.pk }},
|
||||||
}
|
supplier: {{ order.supplier.pk }},
|
||||||
|
|
||||||
function setupCallbacks() {
|
|
||||||
// Setup callbacks for the line buttons
|
|
||||||
|
|
||||||
var table = $("#po-table");
|
|
||||||
|
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||||
table.find(".button-line-edit").click(function() {
|
allow_edit: true,
|
||||||
var pk = $(this).attr('pk');
|
{% else %}
|
||||||
|
allow_edit: false,
|
||||||
constructForm(`/api/order/po-line/${pk}/`, {
|
{% endif %}
|
||||||
fields: {
|
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
|
||||||
part: {
|
allow_receive: true,
|
||||||
filters: {
|
{% else %}
|
||||||
part_detail: true,
|
allow_receive: false,
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
table.find(".button-line-receive").click(function() {
|
|
||||||
var pk = $(this).attr('pk');
|
|
||||||
|
|
||||||
launchModalForm("{% url 'po-receive' order.id %}", {
|
|
||||||
success: reloadTable,
|
|
||||||
data: {
|
|
||||||
line: pk,
|
|
||||||
},
|
|
||||||
secondary: [
|
|
||||||
{
|
|
||||||
field: 'location',
|
|
||||||
label: '{% trans "New Location" %}',
|
|
||||||
title: '{% trans "Create new stock location" %}',
|
|
||||||
url: "{% url 'stock-location-create' %}",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#po-table").inventreeTable({
|
|
||||||
onPostBody: setupCallbacks,
|
|
||||||
name: 'purchaseorder',
|
|
||||||
sidePagination: 'server',
|
|
||||||
formatNoMatches: function() { return "{% trans 'No line items found' %}"; },
|
|
||||||
queryParams: {
|
|
||||||
order: {{ order.id }},
|
|
||||||
part_detail: true,
|
|
||||||
},
|
|
||||||
url: "{% url 'api-po-line-list' %}",
|
|
||||||
showFooter: true,
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
field: 'pk',
|
|
||||||
title: 'ID',
|
|
||||||
visible: false,
|
|
||||||
switchable: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'part',
|
|
||||||
sortable: true,
|
|
||||||
sortName: 'part__part__name',
|
|
||||||
title: '{% trans "Part" %}',
|
|
||||||
switchable: false,
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
if (row.part) {
|
|
||||||
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`);
|
|
||||||
} else {
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
footerFormatter: function() {
|
|
||||||
return '{% trans "Total" %}'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'part_detail.description',
|
|
||||||
title: '{% trans "Description" %}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
sortName: 'part__SKU',
|
|
||||||
field: 'supplier_part_detail.SKU',
|
|
||||||
title: '{% trans "SKU" %}',
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
if (value) {
|
|
||||||
return renderLink(value, `/supplier-part/${row.part}/`);
|
|
||||||
} else {
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
sortName: 'part__MPN',
|
|
||||||
field: 'supplier_part_detail.MPN',
|
|
||||||
title: '{% trans "MPN" %}',
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) {
|
|
||||||
return renderLink(value, `/manufacturer-part/${row.supplier_part_detail.manufacturer_part}/`);
|
|
||||||
} else {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
field: 'reference',
|
|
||||||
title: '{% trans "Reference" %}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
field: 'quantity',
|
|
||||||
title: '{% trans "Quantity" %}',
|
|
||||||
footerFormatter: function(data) {
|
|
||||||
return data.map(function (row) {
|
|
||||||
return +row['quantity']
|
|
||||||
}).reduce(function (sum, i) {
|
|
||||||
return sum + i
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
field: 'purchase_price',
|
|
||||||
title: '{% trans "Unit Price" %}',
|
|
||||||
formatter: function(value, row) {
|
|
||||||
return row.purchase_price_string || row.purchase_price;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
title: '{% trans "Total price" %}',
|
|
||||||
formatter: function(value, row) {
|
|
||||||
var total = row.purchase_price * row.quantity;
|
|
||||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.purchase_price_currency});
|
|
||||||
return formatter.format(total)
|
|
||||||
},
|
|
||||||
footerFormatter: function(data) {
|
|
||||||
var total = data.map(function (row) {
|
|
||||||
return +row['purchase_price']*row['quantity']
|
|
||||||
}).reduce(function (sum, i) {
|
|
||||||
return sum + i
|
|
||||||
}, 0)
|
|
||||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
|
|
||||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
|
|
||||||
return formatter.format(total)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
field: 'received',
|
|
||||||
switchable: false,
|
|
||||||
title: '{% trans "Received" %}',
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
return makeProgressBar(row.received, row.quantity, {
|
|
||||||
id: `order-line-progress-${row.pk}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
sorter: function(valA, valB, rowA, rowB) {
|
|
||||||
|
|
||||||
if (rowA.received == 0 && rowB.received == 0) {
|
|
||||||
return (rowA.quantity > rowB.quantity) ? 1 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
var progressA = parseFloat(rowA.received) / rowA.quantity;
|
|
||||||
var progressB = parseFloat(rowB.received) / rowB.quantity;
|
|
||||||
|
|
||||||
return (progressA < progressB) ? 1 : -1;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'destination',
|
|
||||||
title: '{% trans "Destination" %}',
|
|
||||||
formatter: function(value, row) {
|
|
||||||
if (value) {
|
|
||||||
return renderLink(row.destination_detail.pathstring, `/stock/location/${value}/`);
|
|
||||||
} else {
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'notes',
|
|
||||||
title: '{% trans "Notes" %}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
switchable: false,
|
|
||||||
field: 'buttons',
|
|
||||||
title: '',
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
var html = `<div class='btn-group'>`;
|
|
||||||
|
|
||||||
var pk = row.pk;
|
|
||||||
|
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.delete %}
|
|
||||||
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
|
|
||||||
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
|
|
||||||
if (row.received < row.quantity) {
|
|
||||||
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
|
|
||||||
}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
html += `</div>`;
|
|
||||||
|
|
||||||
return html;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
attachNavCallbacks({
|
attachNavCallbacks({
|
||||||
name: 'purchase-order',
|
name: 'purchase-order',
|
||||||
default: 'order-items'
|
default: 'order-items'
|
||||||
});
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
{% if roles.purchase_order.add %}
|
{% if roles.purchase_order.add %}
|
||||||
<button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>
|
<button class='btn btn-success' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{% block form %}
|
{% block form %}
|
||||||
|
|
||||||
{% blocktrans with desc=order.description %}Receive outstanding parts for <b>{{order}}</b> - <i>{{desc}}</i>{% endblocktrans %}
|
{% blocktrans with desc=order.description %}Receive outstanding parts for <strong>{{order}}</strong> - <em>{{desc}}</em>{% endblocktrans %}
|
||||||
|
|
||||||
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
|
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: {
|
fields: {
|
||||||
|
filename: {},
|
||||||
comment: {},
|
comment: {},
|
||||||
},
|
},
|
||||||
onSuccess: reloadAttachmentTable,
|
onSuccess: reloadAttachmentTable,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
<b>{% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }}</b>
|
<strong>{% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }}</strong>
|
||||||
<br>
|
<br>
|
||||||
{% trans "Shipping this order means that the order will no longer be editable." %}
|
{% trans "Shipping this order means that the order will no longer be editable." %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
{% if roles.sales_order.add %}
|
{% if roles.sales_order.add %}
|
||||||
<button class='btn btn-primary' type='button' id='so-create' title='{% trans "Create new sales order" %}'>
|
<button class='btn btn-success' type='button' id='so-create' title='{% trans "Create new sales order" %}'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "New Sales Order" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Sales Order" %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
<div class='alert alert-block alert-warning'>
|
<div class='alert alert-block alert-warning'>
|
||||||
{% trans "This action will unallocate the following stock from the Sales Order" %}:
|
{% trans "This action will unallocate the following stock from the Sales Order" %}:
|
||||||
<br>
|
<br>
|
||||||
<b>
|
<strong>
|
||||||
{% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }}
|
{% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }}
|
||||||
{% if allocation.item.location %} ({{ allocation.get_location }}){% endif %}
|
{% if allocation.item.location %} ({{ allocation.get_location }}){% endif %}
|
||||||
</b>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+248
-1
@@ -9,8 +9,11 @@ from rest_framework import status
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
|
from InvenTree.status_codes import PurchaseOrderStatus
|
||||||
|
|
||||||
from .models import PurchaseOrder, SalesOrder
|
from stock.models import StockItem
|
||||||
|
|
||||||
|
from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder
|
||||||
|
|
||||||
|
|
||||||
class OrderTest(InvenTreeAPITestCase):
|
class OrderTest(InvenTreeAPITestCase):
|
||||||
@@ -201,6 +204,250 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
response = self.get(url, expected_code=404)
|
response = self.get(url, expected_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseOrderReceiveTest(OrderTest):
|
||||||
|
"""
|
||||||
|
Unit tests for receiving items against a PurchaseOrder
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.assignRole('purchase_order.add')
|
||||||
|
|
||||||
|
self.url = reverse('api-po-receive', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
# Number of stock items which exist at the start of each test
|
||||||
|
self.n = StockItem.objects.count()
|
||||||
|
|
||||||
|
# Mark the order as "placed" so we can receive line items
|
||||||
|
order = PurchaseOrder.objects.get(pk=1)
|
||||||
|
order.status = PurchaseOrderStatus.PLACED
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
"""
|
||||||
|
Test without any POST data
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.post(self.url, {}, expected_code=400).data
|
||||||
|
|
||||||
|
self.assertIn('This field is required', str(data['items']))
|
||||||
|
self.assertIn('This field is required', str(data['location']))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_no_items(self):
|
||||||
|
"""
|
||||||
|
Test with an empty list of items
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": [],
|
||||||
|
"location": None,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Line items must be provided', str(data['items']))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_invalid_items(self):
|
||||||
|
"""
|
||||||
|
Test than errors are returned as expected for invalid data
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"line_item": 12345,
|
||||||
|
"location": 12345
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
items = data['items'][0]
|
||||||
|
|
||||||
|
self.assertIn('Invalid pk "12345"', str(items['line_item']))
|
||||||
|
self.assertIn("object does not exist", str(items['location']))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_invalid_status(self):
|
||||||
|
"""
|
||||||
|
Test with an invalid StockStatus value
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"line_item": 22,
|
||||||
|
"location": 1,
|
||||||
|
"status": 99999,
|
||||||
|
"quantity": 5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('"99999" is not a valid choice.', str(data))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_mismatched_items(self):
|
||||||
|
"""
|
||||||
|
Test for supplier parts which *do* exist but do not match the order supplier
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'line_item': 22,
|
||||||
|
'quantity': 123,
|
||||||
|
'location': 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': None,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Line item does not match purchase order', str(data))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_invalid_barcodes(self):
|
||||||
|
"""
|
||||||
|
Tests for checking in items with invalid barcodes:
|
||||||
|
|
||||||
|
- Cannot check in "duplicate" barcodes
|
||||||
|
- Barcodes cannot match UID field for existing StockItem
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Set stock item barcode
|
||||||
|
item = StockItem.objects.get(pk=1)
|
||||||
|
item.uid = 'MY-BARCODE-HASH'
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'line_item': 1,
|
||||||
|
'quantity': 50,
|
||||||
|
'barcode': 'MY-BARCODE-HASH',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('Barcode is already in use', str(response.data))
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'line_item': 1,
|
||||||
|
'quantity': 5,
|
||||||
|
'barcode': 'MY-BARCODE-HASH-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'line_item': 1,
|
||||||
|
'quantity': 5,
|
||||||
|
'barcode': 'MY-BARCODE-HASH-1'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('barcode values must be unique', str(response.data))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_valid(self):
|
||||||
|
"""
|
||||||
|
Test receipt of valid data
|
||||||
|
"""
|
||||||
|
|
||||||
|
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
|
||||||
|
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
|
||||||
|
|
||||||
|
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
|
||||||
|
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
|
||||||
|
|
||||||
|
self.assertEqual(line_1.received, 0)
|
||||||
|
self.assertEqual(line_2.received, 50)
|
||||||
|
|
||||||
|
# Receive two separate line items against this order
|
||||||
|
self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'line_item': 1,
|
||||||
|
'quantity': 50,
|
||||||
|
'barcode': 'MY-UNIQUE-BARCODE-123',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'line_item': 2,
|
||||||
|
'quantity': 200,
|
||||||
|
'location': 2, # Explicit location
|
||||||
|
'barcode': 'MY-UNIQUE-BARCODE-456',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': 1, # Default location
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
# There should be two newly created stock items
|
||||||
|
self.assertEqual(self.n + 2, StockItem.objects.count())
|
||||||
|
|
||||||
|
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
|
||||||
|
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
|
||||||
|
|
||||||
|
self.assertEqual(line_1.received, 50)
|
||||||
|
self.assertEqual(line_2.received, 250)
|
||||||
|
|
||||||
|
stock_1 = StockItem.objects.filter(supplier_part=line_1.part)
|
||||||
|
stock_2 = StockItem.objects.filter(supplier_part=line_2.part)
|
||||||
|
|
||||||
|
# 1 new stock item created for each supplier part
|
||||||
|
self.assertEqual(stock_1.count(), 1)
|
||||||
|
self.assertEqual(stock_2.count(), 1)
|
||||||
|
|
||||||
|
# Different location for each received item
|
||||||
|
self.assertEqual(stock_1.last().location.pk, 1)
|
||||||
|
self.assertEqual(stock_2.last().location.pk, 2)
|
||||||
|
|
||||||
|
# Barcodes should have been assigned to the stock items
|
||||||
|
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists())
|
||||||
|
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists())
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderTest(OrderTest):
|
class SalesOrderTest(OrderTest):
|
||||||
"""
|
"""
|
||||||
Tests for the SalesOrder API
|
Tests for the SalesOrder API
|
||||||
|
|||||||
+95
-11
@@ -9,12 +9,14 @@ from django.conf.urls import url, include
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.db.models import Q, F, Count, Min, Max, Avg
|
from django.db.models import Q, F, Count, Min, Max, Avg
|
||||||
|
from django.db import transaction
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import filters, serializers
|
from rest_framework import filters, serializers
|
||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
@@ -23,7 +25,7 @@ from djmoney.money import Money
|
|||||||
from djmoney.contrib.exchange.models import convert_money
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from .models import Part, PartCategory, BomItem
|
from .models import Part, PartCategory, BomItem
|
||||||
from .models import PartParameter, PartParameterTemplate
|
from .models import PartParameter, PartParameterTemplate
|
||||||
@@ -31,7 +33,10 @@ from .models import PartAttachment, PartTestTemplate
|
|||||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
|
|
||||||
from stock.models import StockItem
|
from company.models import Company, ManufacturerPart, SupplierPart
|
||||||
|
|
||||||
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from build.models import Build
|
from build.models import Build
|
||||||
|
|
||||||
@@ -630,6 +635,7 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
else:
|
else:
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
We wish to save the user who created this part!
|
We wish to save the user who created this part!
|
||||||
@@ -637,6 +643,8 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
Note: Implementation copied from DRF class CreateModelMixin
|
Note: Implementation copied from DRF class CreateModelMixin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# TODO: Unit tests for this function!
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
@@ -680,21 +688,97 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Optionally create initial stock item
|
# Optionally create initial stock item
|
||||||
try:
|
initial_stock = str2bool(request.data.get('initial_stock', False))
|
||||||
initial_stock = Decimal(request.data.get('initial_stock', 0))
|
|
||||||
|
|
||||||
if initial_stock > 0 and part.default_location is not None:
|
if initial_stock:
|
||||||
|
try:
|
||||||
|
|
||||||
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,
|
part=part,
|
||||||
quantity=initial_stock,
|
manufacturer=manufacturer,
|
||||||
location=part.default_location,
|
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:
|
SupplierPart.objects.create(
|
||||||
pass
|
part=part,
|
||||||
|
supplier=supplier,
|
||||||
|
SKU=sku,
|
||||||
|
manufacturer_part=manufacturer_part,
|
||||||
|
)
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ from stdimage.models import StdImageField
|
|||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
|
from common.settings import currency_code_default
|
||||||
|
|
||||||
from InvenTree import helpers
|
from InvenTree import helpers
|
||||||
from InvenTree import validators
|
from InvenTree import validators
|
||||||
@@ -1514,7 +1516,7 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return (min_price, max_price)
|
return (min_price, max_price)
|
||||||
|
|
||||||
def get_bom_price_range(self, quantity=1, internal=False):
|
def get_bom_price_range(self, quantity=1, internal=False, purchase=False):
|
||||||
""" Return the price range of the BOM for this part.
|
""" Return the price range of the BOM for this part.
|
||||||
Adds the minimum price for all components in the BOM.
|
Adds the minimum price for all components in the BOM.
|
||||||
|
|
||||||
@@ -1531,7 +1533,7 @@ class Part(MPTTModel):
|
|||||||
print("Warning: Item contains itself in BOM")
|
print("Warning: Item contains itself in BOM")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal)
|
prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal, purchase=purchase)
|
||||||
|
|
||||||
if prices is None:
|
if prices is None:
|
||||||
continue
|
continue
|
||||||
@@ -1555,16 +1557,17 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return (min_price, max_price)
|
return (min_price, max_price)
|
||||||
|
|
||||||
def get_price_range(self, quantity=1, buy=True, bom=True, internal=False):
|
def get_price_range(self, quantity=1, buy=True, bom=True, internal=False, purchase=False):
|
||||||
|
|
||||||
""" Return the price range for this part. This price can be either:
|
""" Return the price range for this part. This price can be either:
|
||||||
|
|
||||||
- Supplier price (if purchased from suppliers)
|
- Supplier price (if purchased from suppliers)
|
||||||
- BOM price (if built from other parts)
|
- BOM price (if built from other parts)
|
||||||
- Internal price (if set for the part)
|
- Internal price (if set for the part)
|
||||||
|
- Purchase price (if set for the part)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Minimum of the supplier, BOM or internal price. If no pricing available, returns None
|
Minimum of the supplier, BOM, internal or purchase price. If no pricing available, returns None
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# only get internal price if set and should be used
|
# only get internal price if set and should be used
|
||||||
@@ -1572,6 +1575,12 @@ class Part(MPTTModel):
|
|||||||
internal_price = self.get_internal_price(quantity)
|
internal_price = self.get_internal_price(quantity)
|
||||||
return internal_price, internal_price
|
return internal_price, internal_price
|
||||||
|
|
||||||
|
# only get purchase price if set and should be used
|
||||||
|
if purchase:
|
||||||
|
purchase_price = self.get_purchase_price(quantity)
|
||||||
|
if purchase_price:
|
||||||
|
return purchase_price
|
||||||
|
|
||||||
buy_price_range = self.get_supplier_price_range(quantity) if buy else None
|
buy_price_range = self.get_supplier_price_range(quantity) if buy else None
|
||||||
bom_price_range = self.get_bom_price_range(quantity, internal=internal) if bom else None
|
bom_price_range = self.get_bom_price_range(quantity, internal=internal) if bom else None
|
||||||
|
|
||||||
@@ -1641,6 +1650,13 @@ class Part(MPTTModel):
|
|||||||
def internal_unit_pricing(self):
|
def internal_unit_pricing(self):
|
||||||
return self.get_internal_price(1)
|
return self.get_internal_price(1)
|
||||||
|
|
||||||
|
def get_purchase_price(self, quantity):
|
||||||
|
currency = currency_code_default()
|
||||||
|
prices = [convert_money(item.purchase_price, currency).amount for item in self.stock_items.all() if item.purchase_price]
|
||||||
|
if prices:
|
||||||
|
return min(prices) * quantity, max(prices) * quantity
|
||||||
|
return None
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def copy_bom_from(self, other, clear=True, **kwargs):
|
def copy_bom_from(self, other, clear=True, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
JSON serializers for Part app
|
JSON serializers for Part app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import imghdr
|
import imghdr
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -16,7 +17,9 @@ from djmoney.contrib.django_rest_framework import MoneyField
|
|||||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||||
InvenTreeImageSerializerField,
|
InvenTreeImageSerializerField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
|
InvenTreeAttachmentSerializer,
|
||||||
InvenTreeMoneySerializer)
|
InvenTreeMoneySerializer)
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ class CategorySerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentSerializer(InvenTreeModelSerializer):
|
class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for the PartAttachment class
|
Serializer for the PartAttachment class
|
||||||
"""
|
"""
|
||||||
@@ -65,6 +68,7 @@ class PartAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'part',
|
'part',
|
||||||
'attachment',
|
'attachment',
|
||||||
|
'filename',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
]
|
]
|
||||||
@@ -202,6 +206,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
|||||||
'stock',
|
'stock',
|
||||||
'trackable',
|
'trackable',
|
||||||
'virtual',
|
'virtual',
|
||||||
|
'units',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class='alert alert-block alert-danger'>
|
<div class='alert alert-block alert-danger'>
|
||||||
{% blocktrans with part=part.full_name %}The BOM for <i>{{ part }}</i> has changed, and must be validated.<br>{% endblocktrans %}
|
{% blocktrans with part=part.full_name %}The BOM for <em>{{ part }}</em> has changed, and must be validated.<br>{% endblocktrans %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% blocktrans with part=part.full_name checker=part.bom_checked_by check_date=part.bom_checked_date %}The BOM for <i>{{ part }}</i> was last checked by {{ checker }} on {{ check_date }}{% endblocktrans %}
|
{% blocktrans with part=part.full_name checker=part.bom_checked_by check_date=part.bom_checked_date %}The BOM for <em>{{ part }}</em> was last checked by {{ checker }} on {{ check_date }}{% endblocktrans %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class='alert alert-danger alert-block'>
|
<div class='alert alert-danger alert-block'>
|
||||||
<b>{% blocktrans with part=part.full_name %}The BOM for <i>{{ part }}</i> has not been validated.{% endblocktrans %}</b>
|
<strong>{% blocktrans with part=part.full_name %}The BOM for <em>{{ part }}</em> has not been validated.{% endblocktrans %}</strong>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
{% if part.has_bom %}
|
{% if part.has_bom %}
|
||||||
<div class='alert alert-block alert-danger'>
|
<div class='alert alert-block alert-danger'>
|
||||||
<b>{% trans "Warning" %}</b><br>
|
<strong>{% trans "Warning" %}</strong><br>
|
||||||
{% trans "This part already has a Bill of Materials" %}<br>
|
{% trans "This part already has a Bill of Materials" %}<br>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
{% for duplicate in duplicates %}
|
{% for duplicate in duplicates %}
|
||||||
{% if duplicate == col.value %}
|
{% if duplicate == col.value %}
|
||||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||||
<b>{% trans "Duplicate selection" %}</b>
|
<strong>{% trans "Duplicate selection" %}</strong>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -43,9 +43,9 @@
|
|||||||
|
|
||||||
{% block form_alert %}
|
{% block form_alert %}
|
||||||
<div class='alert alert-info alert-block'>
|
<div class='alert alert-info alert-block'>
|
||||||
<b>{% trans "Requirements for BOM upload" %}:</b>
|
<strong>{% trans "Requirements for BOM upload" %}:</strong>
|
||||||
<ul>
|
<ul>
|
||||||
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <b><a href="/part/bom_template/">{% trans "BOM Upload Template" %}</a></b></li>
|
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href="/part/bom_template/">{% trans "BOM Upload Template" %}</a></strong></li>
|
||||||
<li>{% trans "Each part must already exist in the database" %}</li>
|
<li>{% trans "Each part must already exist in the database" %}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
{% blocktrans with part.full_name as part %}Confirm that the Bill of Materials (BOM) is valid for:<br><i>{{ part }}</i>{% endblocktrans %}
|
{% blocktrans with part.full_name as part %}Confirm that the Bill of Materials (BOM) is valid for:<br><em>{{ part }}</em>{% endblocktrans %}
|
||||||
|
|
||||||
<div class='alert alert-warning alert-block'>
|
<div class='alert alert-warning alert-block'>
|
||||||
{% trans 'This will validate each line in the BOM.' %}
|
{% trans 'This will validate each line in the BOM.' %}
|
||||||
|
|||||||
@@ -132,12 +132,13 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class='btn-group'>
|
<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'>
|
<ul class='dropdown-menu'>
|
||||||
{% if roles.part.change %}
|
{% if roles.part.change %}
|
||||||
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
|
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||||
|
<li><a href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>{% trans "Print Labels" %}</a></li>
|
||||||
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
|
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,6 +277,7 @@
|
|||||||
constructForm('{% url "api-part-list" %}', {
|
constructForm('{% url "api-part-list" %}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
fields: fields,
|
fields: fields,
|
||||||
|
groups: partGroups(),
|
||||||
title: '{% trans "Create Part" %}',
|
title: '{% trans "Create Part" %}',
|
||||||
onSuccess: function(data) {
|
onSuccess: function(data) {
|
||||||
// Follow the new part
|
// Follow the new part
|
||||||
@@ -336,4 +338,4 @@
|
|||||||
default: 'part-stock'
|
default: 'part-stock'
|
||||||
});
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
|
|
||||||
{% if matches %}
|
{% if matches %}
|
||||||
<div class='alert alert-block alert-warning'>
|
<div class='alert alert-block alert-warning'>
|
||||||
<b>{% trans "Possible Matching Parts" %}</b>
|
<strong>{% trans "Possible Matching Parts" %}</strong>
|
||||||
<p>{% trans "The new part may be a duplicate of these existing parts" %}:</p>
|
<p>{% trans "The new part may be a duplicate of these existing parts" %}:</p>
|
||||||
<ul class='list-group'>
|
<ul class='list-group'>
|
||||||
{% for match in matches %}
|
{% for match in matches %}
|
||||||
<li class='list-group-item list-group-item-condensed'>
|
<li class='list-group-item list-group-item-condensed'>
|
||||||
{% decimal match.ratio as match_per %}
|
{% decimal match.ratio as match_per %}
|
||||||
{% blocktrans with full_name=match.part.full_name desc=match.part.description %}{{full_name}} - <i>{{desc}}</i> ({{match_per}}% match){% endblocktrans %}
|
{% blocktrans with full_name=match.part.full_name desc=match.part.description %}{{full_name}} - <em>{{desc}}</em> ({{match_per}}% match){% endblocktrans %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
{% if part.is_template %}
|
{% if part.is_template %}
|
||||||
<div class='alert alert-info alert-block'>
|
<div class='alert alert-info alert-block'>
|
||||||
{% blocktrans with full_name=part.full_name%}Showing stock for all variants of <i>{{full_name}}</i>{% endblocktrans %}
|
{% blocktrans with full_name=part.full_name%}Showing stock for all variants of <em>{{full_name}}</em>{% endblocktrans %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% include "stock_table.html" %}
|
{% include "stock_table.html" %}
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
<div id='so-button-bar'>
|
<div id='so-button-bar'>
|
||||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||||
{% if 0 %}
|
{% if 0 %}
|
||||||
<button class='btn btn-primary' type='button' id='part-order2' title='{% trans "New sales order" %}'>{% trans "New Order" %}</button>
|
<button class='btn btn-success' type='button' id='part-order2' title='{% trans "New sales order" %}'>{% trans "New Order" %}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class='filter-list' id='filter-list-salesorder'>
|
<div class='filter-list' id='filter-list-salesorder'>
|
||||||
<!-- An empty div in which the filter list will be constructed -->
|
<!-- An empty div in which the filter list will be constructed -->
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
<div id='related-button-bar'>
|
<div id='related-button-bar'>
|
||||||
<div class='button-toolbar container-fluid' style='float: left;'>
|
<div class='button-toolbar container-fluid' style='float: left;'>
|
||||||
{% if roles.part.change %}
|
{% if roles.part.change %}
|
||||||
<button class='btn btn-primary' type='button' id='add-related-part' title='{% trans "Add Related" %}'>{% trans "Add Related" %}</button>
|
<button class='btn btn-success' type='button' id='add-related-part' title='{% trans "Add Related" %}'>{% trans "Add Related" %}</button>
|
||||||
<div class='filter-list' id='filter-list-related'>
|
<div class='filter-list' id='filter-list-related'>
|
||||||
<!-- An empty div in which the filter list will be constructed -->
|
<!-- An empty div in which the filter list will be constructed -->
|
||||||
</div>
|
</div>
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
||||||
</button>
|
</button>
|
||||||
<div id='opt-dropdown' class="btn-group">
|
<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">
|
<ul class="dropdown-menu">
|
||||||
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -312,7 +312,7 @@
|
|||||||
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
||||||
</button>
|
</button>
|
||||||
<div id='opt-dropdown' class="btn-group">
|
<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">
|
<ul class="dropdown-menu">
|
||||||
<li><a href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'>{% trans "Delete" %}</a></li>
|
<li><a href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'>{% trans "Delete" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -667,6 +667,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onPanelLoad("test-templates", function() {
|
onPanelLoad("test-templates", function() {
|
||||||
|
|
||||||
|
// Load test template table
|
||||||
loadPartTestTemplateTable(
|
loadPartTestTemplateTable(
|
||||||
$("#test-template-table"),
|
$("#test-template-table"),
|
||||||
{
|
{
|
||||||
@@ -677,11 +679,8 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Callback for "add test template" button
|
||||||
$("#add-test-template").click(function() {
|
$("#add-test-template").click(function() {
|
||||||
|
|
||||||
function reloadTestTemplateTable() {
|
|
||||||
$("#test-template-table").bootstrapTable("refresh");
|
|
||||||
}
|
|
||||||
|
|
||||||
constructForm('{% url "api-part-test-template-list" %}', {
|
constructForm('{% url "api-part-test-template-list" %}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -697,39 +696,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
title: '{% trans "Add Test Result Template" %}',
|
title: '{% trans "Add Test Result Template" %}',
|
||||||
onSuccess: reloadTestTemplateTable
|
onSuccess: function() {
|
||||||
|
$("#test-template-table").bootstrapTable("refresh");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#test-template-table").on('click', '.button-test-edit', function() {
|
|
||||||
var pk = $(this).attr('pk');
|
|
||||||
|
|
||||||
var url = `/api/part/test-template/${pk}/`;
|
|
||||||
|
|
||||||
constructForm(url, {
|
|
||||||
fields: {
|
|
||||||
test_name: {},
|
|
||||||
description: {},
|
|
||||||
required: {},
|
|
||||||
requires_value: {},
|
|
||||||
requires_attachment: {},
|
|
||||||
},
|
|
||||||
title: '{% trans "Edit Test Result Template" %}',
|
|
||||||
onSuccess: reloadTestTemplateTable,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#test-template-table").on('click', '.button-test-delete', function() {
|
|
||||||
var pk = $(this).attr('pk');
|
|
||||||
|
|
||||||
var url = `/api/part/test-template/${pk}/`;
|
|
||||||
|
|
||||||
constructForm(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
title: '{% trans "Delete Test Result Template" %}',
|
|
||||||
onSuccess: reloadTestTemplateTable,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -868,6 +838,7 @@
|
|||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: {
|
fields: {
|
||||||
|
filename: {},
|
||||||
comment: {},
|
comment: {},
|
||||||
},
|
},
|
||||||
title: '{% trans "Edit Attachment" %}',
|
title: '{% trans "Edit Attachment" %}',
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
{% for duplicate in duplicates %}
|
{% for duplicate in duplicates %}
|
||||||
{% if duplicate == col.value %}
|
{% if duplicate == col.value %}
|
||||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||||
<b>{% trans "Duplicate selection" %}</b>
|
<strong>{% trans "Duplicate selection" %}</strong>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
{% for duplicate in duplicates %}
|
{% for duplicate in duplicates %}
|
||||||
{% if duplicate == col.value %}
|
{% if duplicate == col.value %}
|
||||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||||
<b>{% trans "Duplicate selection" %}</b>
|
<strong>{% trans "Duplicate selection" %}</strong>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "part/part_app_base.html" %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
|
{% block menubar %}
|
||||||
|
<ul class='list-group'>
|
||||||
|
<li class='list-group-item'>
|
||||||
|
<a href='#' id='part-menu-toggle'>
|
||||||
|
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class='list-group-item' title='{% trans "Return To Parts" %}'>
|
||||||
|
<a href='{% url "part-index" %}' id='select-upload-file' class='nav-toggle'>
|
||||||
|
<span class='fas fa-undo side-icon'></span>
|
||||||
|
{% trans "Return To Parts" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class='panel panel-default panel-inventree'>
|
<div class='panel panel-default panel-inventree'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
@@ -54,4 +70,9 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
enableNavbar({
|
||||||
|
label: 'part',
|
||||||
|
toggleId: '#part-menu-toggle',
|
||||||
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
||||||
|
{% settings_value 'BUILD_FUNCTION_ENABLE' as enable_build %}
|
||||||
|
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
|
||||||
|
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
|
||||||
|
{% settings_value 'SO_FUNCTION_ENABLE' as enable_so %}
|
||||||
|
{% settings_value 'BUY_FUNCTION_ENABLE' as enable_buy %}
|
||||||
|
{% settings_value 'SELL_FUNCTION_ENABLE' as enable_sell %}
|
||||||
|
|
||||||
<ul class='list-group'>
|
<ul class='list-group'>
|
||||||
<li class='list-group-item'>
|
<li class='list-group-item'>
|
||||||
@@ -25,12 +31,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if enable_stock %}
|
||||||
<li class='list-group-item' title='{% trans "Stock Items" %}'>
|
<li class='list-group-item' title='{% trans "Stock Items" %}'>
|
||||||
<a href='#' id='select-part-stock' class='nav-toggle'>
|
<a href='#' id='select-part-stock' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-boxes sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-boxes sidebar-icon'></span>
|
||||||
{% trans "Stock" %}
|
{% trans "Stock" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
{% if part.assembly %}
|
{% if part.assembly %}
|
||||||
<li class='list-group-item' title='{% trans "Bill of Materials" %}'>
|
<li class='list-group-item' title='{% trans "Bill of Materials" %}'>
|
||||||
<a href='#' id='select-bom' class='nav-toggle'>
|
<a href='#' id='select-bom' class='nav-toggle'>
|
||||||
@@ -38,7 +46,7 @@
|
|||||||
{% trans "Bill of Materials" %}
|
{% trans "Bill of Materials" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if roles.build.view %}
|
{% if roles.build.view and enable_build %}
|
||||||
<li class='list-group-item ' title='{% trans "Build Orders" %}'>
|
<li class='list-group-item ' title='{% trans "Build Orders" %}'>
|
||||||
<a href='#' id='select-build-orders' class='nav-toggle'>
|
<a href='#' id='select-build-orders' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-tools sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-tools sidebar-icon'></span>
|
||||||
@@ -55,19 +63,22 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if enable_buy or enable_sell %}
|
||||||
<li class='list-group-item' title='{% trans "Pricing Information" %}'>
|
<li class='list-group-item' title='{% trans "Pricing Information" %}'>
|
||||||
<a href='#' id='select-pricing' class='nav-toggle'>
|
<a href='#' id='select-pricing' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
|
||||||
{% trans "Prices" %}
|
{% trans "Prices" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if part.purchaseable and roles.purchase_order.view %}
|
{% endif %}
|
||||||
|
{% if part.purchaseable and roles.purchase_order.view and enable_buy %}
|
||||||
<li class='list-group-item' title='{% trans "Suppliers" %}'>
|
<li class='list-group-item' title='{% trans "Suppliers" %}'>
|
||||||
<a href='#' id='select-suppliers' class='nav-toggle'>
|
<a href='#' id='select-suppliers' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-building sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-building sidebar-icon'></span>
|
||||||
{% trans "Suppliers" %}
|
{% trans "Suppliers" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if enable_po %}
|
||||||
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
|
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
|
||||||
<a href='#' id='select-purchase-orders' class='nav-toggle'>
|
<a href='#' id='select-purchase-orders' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-shopping-cart sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-shopping-cart sidebar-icon'></span>
|
||||||
@@ -75,7 +86,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if roles.sales_order.view %}
|
{% endif %}
|
||||||
|
{% if part.salable and roles.sales_order.view and enable_sell and enable_so %}
|
||||||
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
|
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
|
||||||
<a href='#' id='select-sales-orders' class='nav-toggle'>
|
<a href='#' id='select-sales-orders' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-truck sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-truck sidebar-icon'></span>
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
{% settings_value 'BUY_FUNCTION_ENABLE' as enable_buy %}
|
||||||
|
{% settings_value 'SELL_FUNCTION_ENABLE' as enable_sell %}
|
||||||
|
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
|
||||||
|
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
|
||||||
|
|
||||||
<div class="panel panel-default panel-inventree">
|
<div class="panel panel-default panel-inventree">
|
||||||
<!-- Default panel contents -->
|
<!-- Default panel contents -->
|
||||||
<div class="panel-heading"><h3>{{ part.full_name }}</h3></div>
|
<div class="panel-heading"><h3>{{ part.full_name }}</h3></div>
|
||||||
@@ -80,10 +85,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.active %}
|
{% if part.active %}
|
||||||
|
{% if enable_buy or enable_sell %}
|
||||||
<button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'>
|
<button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'>
|
||||||
<span id='part-price-icon' class='fas fa-dollar-sign'/>
|
<span id='part-price-icon' class='fas fa-dollar-sign'/>
|
||||||
</button>
|
</button>
|
||||||
{% if roles.stock.change %}
|
{% endif %}
|
||||||
|
{% if roles.stock.change and enable_stock %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||||
<span class='fas fa-boxes'></span> <span class='caret'></span>
|
<span class='fas fa-boxes'></span> <span class='caret'></span>
|
||||||
@@ -104,8 +111,8 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.purchaseable %}
|
{% if part.purchaseable and roles.purchase_order.add %}
|
||||||
{% if roles.purchase_order.add %}
|
{% if enable_buy and enable_po %}
|
||||||
<button type='button' class='btn btn-default' id='part-order' title='{% trans "Order part" %}'>
|
<button type='button' class='btn btn-default' id='part-order' title='{% trans "Order part" %}'>
|
||||||
<span id='part-order-icon' class='fas fa-shopping-cart'/>
|
<span id='part-order-icon' class='fas fa-shopping-cart'/>
|
||||||
</button>
|
</button>
|
||||||
@@ -123,7 +130,7 @@
|
|||||||
{% if roles.part.change %}
|
{% if roles.part.change %}
|
||||||
<li><a href='#' id='part-edit'><span class='fas fa-edit icon-blue'></span> {% trans "Edit part" %}</a></li>
|
<li><a href='#' id='part-edit'><span class='fas fa-edit icon-blue'></span> {% trans "Edit part" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not part.active and roles.part.delete %}
|
{% if roles.part.delete %}
|
||||||
<li><a href='#' id='part-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete part" %}</a></li>
|
<li><a href='#' id='part-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete part" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -496,12 +503,13 @@
|
|||||||
});
|
});
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not part.active and roles.part.delete %}
|
{% if roles.part.delete %}
|
||||||
$("#part-delete").click(function() {
|
$("#part-delete").click(function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'part-delete' part.id %}",
|
"{% url 'part-delete' part.id %}",
|
||||||
{
|
{
|
||||||
redirect: {% if part.category %}"{% url 'category-detail' part.category.id %}"{% else %}"{% url 'part-index' %}"{% endif %}
|
redirect: {% if part.category %}"{% url 'category-detail' part.category.id %}"{% else %}"{% url 'part-index' %}"{% endif %},
|
||||||
|
no_post: {% if part.active %}true{% else %}false{% endif %},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
|
|
||||||
<table class='table table-striped table-condensed table-price-two'>
|
<table class='table table-striped table-condensed table-price-two'>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Part' %}</b></td>
|
<td><strong>{% trans 'Part' %}</strong></td>
|
||||||
<td>{{ part }}</td>
|
<td>{{ part }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Quantity' %}</b></td>
|
<td><strong>{% trans 'Quantity' %}</strong></td>
|
||||||
<td>{{ quantity }}</td>
|
<td>{{ quantity }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -23,13 +23,13 @@
|
|||||||
<table class='table table-striped table-condensed table-price-three'>
|
<table class='table table-striped table-condensed table-price-three'>
|
||||||
{% if min_total_buy_price %}
|
{% if min_total_buy_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Unit Cost' %}</b></td>
|
<td><strong>{% trans 'Unit Cost' %}</strong></td>
|
||||||
<td>Min: {% include "price.html" with price=min_unit_buy_price %}</td>
|
<td>Min: {% include "price.html" with price=min_unit_buy_price %}</td>
|
||||||
<td>Max: {% include "price.html" with price=max_unit_buy_price %}</td>
|
<td>Max: {% include "price.html" with price=max_unit_buy_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if quantity > 1 %}
|
{% if quantity > 1 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Total Cost' %}</b></td>
|
<td><strong>{% trans 'Total Cost' %}</strong></td>
|
||||||
<td>Min: {% include "price.html" with price=min_total_buy_price %}</td>
|
<td>Min: {% include "price.html" with price=min_total_buy_price %}</td>
|
||||||
<td>Max: {% include "price.html" with price=max_total_buy_price %}</td>
|
<td>Max: {% include "price.html" with price=max_total_buy_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan='3'>
|
<td colspan='3'>
|
||||||
<span class='warning-msg'><i>{% trans 'No supplier pricing available' %}</i></span>
|
<span class='warning-msg'><em>{% trans 'No supplier pricing available' %}</em></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -49,28 +49,43 @@
|
|||||||
<table class='table table-striped table-condensed table-price-three'>
|
<table class='table table-striped table-condensed table-price-three'>
|
||||||
{% if min_total_bom_price %}
|
{% if min_total_bom_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Unit Cost' %}</b></td>
|
<td><strong>{% trans 'Unit Cost' %}</strong></td>
|
||||||
<td>Min: {% include "price.html" with price=min_unit_bom_price %}</td>
|
<td>Min: {% include "price.html" with price=min_unit_bom_price %}</td>
|
||||||
<td>Max: {% include "price.html" with price=max_unit_bom_price %}</td>
|
<td>Max: {% include "price.html" with price=max_unit_bom_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if quantity > 1 %}
|
{% if quantity > 1 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Total Cost' %}</b></td>
|
<td><strong>{% trans 'Total Cost' %}</strong></td>
|
||||||
<td>Min: {% include "price.html" with price=min_total_bom_price %}</td>
|
<td>Min: {% include "price.html" with price=min_total_bom_price %}</td>
|
||||||
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
|
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if min_total_bom_purchase_price %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{% trans 'Unit Purchase Price' %}</strong></td>
|
||||||
|
<td>Min: {% include "price.html" with price=min_unit_bom_purchase_price %}</td>
|
||||||
|
<td>Max: {% include "price.html" with price=max_unit_bom_purchase_price %}</td>
|
||||||
|
</tr>
|
||||||
|
{% if quantity > 1 %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{% trans 'Total Purchase Price' %}</strong></td>
|
||||||
|
<td>Min: {% include "price.html" with price=min_total_bom_purchase_price %}</td>
|
||||||
|
<td>Max: {% include "price.html" with price=max_total_bom_purchase_price %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if part.has_complete_bom_pricing == False %}
|
{% if part.has_complete_bom_pricing == False %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan='3'>
|
<td colspan='3'>
|
||||||
<span class='warning-msg'><i>{% trans 'Note: BOM pricing is incomplete for this part' %}</i></span>
|
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan='3'>
|
<td colspan='3'>
|
||||||
<span class='warning-msg'><i>{% trans 'No BOM pricing available' %}</i></span>
|
<span class='warning-msg'><em>{% trans 'No BOM pricing available' %}</em></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -82,11 +97,11 @@
|
|||||||
<h4>{% trans 'Internal Price' %}</h4>
|
<h4>{% trans 'Internal Price' %}</h4>
|
||||||
<table class='table table-striped table-condensed table-price-two'>
|
<table class='table table-striped table-condensed table-price-two'>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Unit Cost' %}</b></td>
|
<td><strong>{% trans 'Unit Cost' %}</strong></td>
|
||||||
<td>{% include "price.html" with price=unit_internal_part_price %}</td>
|
<td>{% include "price.html" with price=unit_internal_part_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Total Cost' %}</b></td>
|
<td><strong>{% trans 'Total Cost' %}</strong></td>
|
||||||
<td>{% include "price.html" with price=total_internal_part_price %}</td>
|
<td>{% include "price.html" with price=total_internal_part_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -97,11 +112,11 @@
|
|||||||
<h4>{% trans 'Sale Price' %}</h4>
|
<h4>{% trans 'Sale Price' %}</h4>
|
||||||
<table class='table table-striped table-condensed table-price-two'>
|
<table class='table table-striped table-condensed table-price-two'>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Unit Cost' %}</b></td>
|
<td><strong>{% trans 'Unit Cost' %}</strong></td>
|
||||||
<td>{% include "price.html" with price=unit_part_price %}</td>
|
<td>{% include "price.html" with price=unit_part_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Total Cost' %}</b></td>
|
<td><strong>{% trans 'Total Cost' %}</strong></td>
|
||||||
<td>{% include "price.html" with price=total_part_price %}</td>
|
<td>{% include "price.html" with price=total_part_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -3,8 +3,18 @@
|
|||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
|
|
||||||
|
{% if part.active %}
|
||||||
|
|
||||||
<div class='alert alert-block alert-danger'>
|
<div class='alert alert-block alert-danger'>
|
||||||
{% blocktrans with full_name=part.full_name %}Are you sure you want to delete part '<b>{{full_name}}</b>'?{% endblocktrans %}
|
{% blocktrans with full_name=part.full_name %}Part '<strong>{{full_name}}</strong>' cannot be deleted as it is still marked as <strong>active</strong>.
|
||||||
|
<br>Disable the "Active" part attribute and re-try.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class='alert alert-block alert-danger'>
|
||||||
|
{% blocktrans with full_name=part.full_name %}Are you sure you want to delete part '<strong>{{full_name}}</strong>'?{% endblocktrans %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if part.used_in_count %}
|
{% if part.used_in_count %}
|
||||||
@@ -55,4 +65,12 @@
|
|||||||
<p>{% blocktrans with count=part.serials.all|length full_name=part.full_name %}There are {{count}} unique parts tracked for '{{full_name}}'. Deleting this part will permanently remove this tracking information.{% endblocktrans %}</p>
|
<p>{% blocktrans with count=part.serials.all|length full_name=part.full_name %}There are {{count}} unique parts tracked for '{{full_name}}'. Deleting this part will permanently remove this tracking information.{% endblocktrans %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block form %}
|
||||||
|
{% if not part.active %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
{% if part.supplier_count > 0 %}
|
{% if part.supplier_count > 0 %}
|
||||||
{% if min_total_buy_price %}
|
{% if min_total_buy_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Supplier Pricing' %}</b>
|
<td><strong>{% trans 'Supplier Pricing' %}</strong>
|
||||||
<a href="#supplier-cost" title='{% trans "Show supplier cost" %}'><span class="fas fa-search-dollar"></span></a>
|
<a href="#supplier-cost" title='{% trans "Show supplier cost" %}'><span class="fas fa-search-dollar"></span></a>
|
||||||
<a href="#purchase-price" title='{% trans "Show purchase price" %}'><span class="fas fa-chart-bar"></span></a>
|
<a href="#purchase-price" title='{% trans "Show purchase price" %}'><span class="fas fa-chart-bar"></span></a>
|
||||||
</td>
|
</td>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan='4'>
|
<td colspan='4'>
|
||||||
<span class='warning-msg'><i>{% trans 'No supplier pricing available' %}</i></span>
|
<span class='warning-msg'><em>{% trans 'No supplier pricing available' %}</em></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
{% if part.bom_count > 0 %}
|
{% if part.bom_count > 0 %}
|
||||||
{% if min_total_bom_price %}
|
{% if min_total_bom_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'BOM Pricing' %}</b>
|
<td><strong>{% trans 'BOM Pricing' %}</strong>
|
||||||
<a href="#bom-cost" title='{% trans "Show BOM cost" %}'><span class="fas fa-search-dollar"></span></a>
|
<a href="#bom-cost" title='{% trans "Show BOM cost" %}'><span class="fas fa-search-dollar"></span></a>
|
||||||
</td>
|
</td>
|
||||||
<td>{% trans 'Unit Cost' %}</td>
|
<td>{% trans 'Unit Cost' %}</td>
|
||||||
@@ -61,17 +61,36 @@
|
|||||||
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
|
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% if min_total_bom_purchase_price %}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>{% trans 'Unit Purchase Price' %}</td>
|
||||||
|
<td>Min: {% include "price.html" with price=min_unit_bom_purchase_price %}</td>
|
||||||
|
<td>Max: {% include "price.html" with price=max_unit_bom_purchase_price %}</td>
|
||||||
|
</tr>
|
||||||
|
{% if quantity > 1 %}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>{% trans 'Total Purchase Price' %}</td>
|
||||||
|
<td>Min: {% include "price.html" with price=min_total_bom_purchase_price %}</td>
|
||||||
|
<td>Max: {% include "price.html" with price=max_total_bom_purchase_price %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if part.has_complete_bom_pricing == False %}
|
{% if part.has_complete_bom_pricing == False %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan='4'>
|
<td colspan='4'>
|
||||||
<span class='warning-msg'><i>{% trans 'Note: BOM pricing is incomplete for this part' %}</i></span>
|
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan='4'>
|
<td colspan='4'>
|
||||||
<span class='warning-msg'><i>{% trans 'No BOM pricing available' %}</i></span>
|
<span class='warning-msg'><em>{% trans 'No BOM pricing available' %}</em></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -80,7 +99,7 @@
|
|||||||
{% if show_internal_price and roles.sales_order.view %}
|
{% if show_internal_price and roles.sales_order.view %}
|
||||||
{% if total_internal_part_price %}
|
{% if total_internal_part_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Internal Price' %}</b></td>
|
<td><strong>{% trans 'Internal Price' %}</strong></td>
|
||||||
<td>{% trans 'Unit Cost' %}</td>
|
<td>{% trans 'Unit Cost' %}</td>
|
||||||
<td colspan='2'>{% include "price.html" with price=unit_internal_part_price %}</td>
|
<td colspan='2'>{% include "price.html" with price=unit_internal_part_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -94,7 +113,7 @@
|
|||||||
|
|
||||||
{% if total_part_price %}
|
{% if total_part_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Sale Price' %}</b>
|
<td><strong>{% trans 'Sale Price' %}</strong>
|
||||||
<a href="#sale-cost" title='{% trans "Show sale cost" %}'><span class="fas fa-search-dollar"></span></a>
|
<a href="#sale-cost" title='{% trans "Show sale cost" %}'><span class="fas fa-search-dollar"></span></a>
|
||||||
<a href="#sale-price" title='{% trans "Show sale price" %}'><span class="fas fa-chart-bar"></span></a>
|
<a href="#sale-price" title='{% trans "Show sale price" %}'><span class="fas fa-chart-bar"></span></a>
|
||||||
</td>
|
</td>
|
||||||
@@ -160,8 +179,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
<h4>{% trans 'Stock Pricing' %}
|
<h4>{% trans 'Stock Pricing' %}
|
||||||
<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
|
<em class="fas fa-info-circle" title="Shows the purchase prices of stock for this part. The Supplier Unit Cost is the current purchase price for that supplier part."></em>
|
||||||
The Supplier Unit Cost is the current purchase price for that supplier part."></i>
|
|
||||||
</h4>
|
</h4>
|
||||||
{% if price_history|length > 0 %}
|
{% if price_history|length > 0 %}
|
||||||
<div style="max-width: 99%; min-height: 300px">
|
<div style="max-width: 99%; min-height: 300px">
|
||||||
@@ -193,7 +211,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col col-md-4">
|
<div class="col col-md-4">
|
||||||
<div id='internal-price-break-toolbar' class='btn-group'>
|
<div id='internal-price-break-toolbar' class='btn-group'>
|
||||||
<button class='btn btn-primary' id='new-internal-price-break' type='button'>
|
<button class='btn btn-success' id='new-internal-price-break' type='button'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %}
|
<span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,7 +267,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col col-md-4">
|
<div class="col col-md-4">
|
||||||
<div id='price-break-toolbar' class='btn-group'>
|
<div id='price-break-toolbar' class='btn-group'>
|
||||||
<button class='btn btn-primary' id='new-price-break' type='button'>
|
<button class='btn btn-success' id='new-price-break' type='button'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "Add Price Break" %}
|
<span class='fas fa-plus-circle'></span> {% trans "Add Price Break" %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
<div class='alert alert-info alert-block'>
|
<div class='alert alert-info alert-block'>
|
||||||
<b>{% trans "Create new part variant" %}</b><br>
|
<strong>{% trans "Create new part variant" %}</strong><br>
|
||||||
{% blocktrans with full_name=part.full_name %}Create a new variant of template <i>'{{full_name}}'</i>.{% endblocktrans %}
|
{% blocktrans with full_name=part.full_name %}Create a new variant of template <em>'{{full_name}}'</em>.{% endblocktrans %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,6 +6,7 @@ over and above the built-in Django tags.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.conf import settings as djangosettings
|
from django.conf import settings as djangosettings
|
||||||
@@ -135,6 +136,21 @@ def inventree_version(*args, **kwargs):
|
|||||||
return version.inventreeVersion()
|
return version.inventreeVersion()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def inventree_is_development(*args, **kwargs):
|
||||||
|
return version.isInvenTreeDevelopmentVersion()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def inventree_is_release(*args, **kwargs):
|
||||||
|
return not version.isInvenTreeDevelopmentVersion()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def inventree_docs_version(*args, **kwargs):
|
||||||
|
return version.inventreeDocsVersion()
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def inventree_api_version(*args, **kwargs):
|
def inventree_api_version(*args, **kwargs):
|
||||||
""" Return InvenTree API version """
|
""" Return InvenTree API version """
|
||||||
@@ -168,7 +184,10 @@ def inventree_github_url(*args, **kwargs):
|
|||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def inventree_docs_url(*args, **kwargs):
|
def inventree_docs_url(*args, **kwargs):
|
||||||
""" Return URL for InvenTree documenation site """
|
""" Return URL for InvenTree documenation site """
|
||||||
return "https://inventree.readthedocs.io/"
|
|
||||||
|
tag = version.inventreeDocsVersion()
|
||||||
|
|
||||||
|
return f"https://inventree.readthedocs.io/en/{tag}"
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
@@ -262,6 +281,26 @@ def get_available_themes(*args, **kwargs):
|
|||||||
return themes
|
return themes
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def primitive_to_javascript(primitive):
|
||||||
|
"""
|
||||||
|
Convert a python primitive to a javascript primitive.
|
||||||
|
|
||||||
|
e.g. True -> true
|
||||||
|
'hello' -> '"hello"'
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(primitive) is bool:
|
||||||
|
return str(primitive).lower()
|
||||||
|
|
||||||
|
elif type(primitive) in [int, float]:
|
||||||
|
return primitive
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Wrap with quotes
|
||||||
|
return format_html("'{}'", primitive)
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def keyvalue(dict, key):
|
def keyvalue(dict, key):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
'location',
|
'location',
|
||||||
'bom',
|
'bom',
|
||||||
'test_templates',
|
'test_templates',
|
||||||
|
'company',
|
||||||
]
|
]
|
||||||
|
|
||||||
roles = [
|
roles = [
|
||||||
@@ -465,6 +466,149 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertFalse(response.data['active'])
|
self.assertFalse(response.data['active'])
|
||||||
self.assertFalse(response.data['purchaseable'])
|
self.assertFalse(response.data['purchaseable'])
|
||||||
|
|
||||||
|
def test_initial_stock(self):
|
||||||
|
"""
|
||||||
|
Tests for initial stock quantity creation
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
|
# Track how many parts exist at the start of this test
|
||||||
|
n = Part.objects.count()
|
||||||
|
|
||||||
|
# Set up required part data
|
||||||
|
data = {
|
||||||
|
'category': 1,
|
||||||
|
'name': "My lil' test part",
|
||||||
|
'description': 'A part with which to test',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Signal that we want to add initial stock
|
||||||
|
data['initial_stock'] = True
|
||||||
|
|
||||||
|
# Post without a quantity
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('initial_stock_quantity', response.data)
|
||||||
|
|
||||||
|
# Post with an invalid quantity
|
||||||
|
data['initial_stock_quantity'] = "ax"
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('initial_stock_quantity', response.data)
|
||||||
|
|
||||||
|
# Post with a negative quantity
|
||||||
|
data['initial_stock_quantity'] = -1
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('Must be greater than zero', response.data['initial_stock_quantity'])
|
||||||
|
|
||||||
|
# Post with a valid quantity
|
||||||
|
data['initial_stock_quantity'] = 12345
|
||||||
|
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('initial_stock_location', response.data)
|
||||||
|
|
||||||
|
# Check that the number of parts has not increased (due to form failures)
|
||||||
|
self.assertEqual(Part.objects.count(), n)
|
||||||
|
|
||||||
|
# Now, set a location
|
||||||
|
data['initial_stock_location'] = 1
|
||||||
|
|
||||||
|
response = self.post(url, data, expected_code=201)
|
||||||
|
|
||||||
|
# Check that the part has been created
|
||||||
|
self.assertEqual(Part.objects.count(), n + 1)
|
||||||
|
|
||||||
|
pk = response.data['pk']
|
||||||
|
|
||||||
|
new_part = Part.objects.get(pk=pk)
|
||||||
|
|
||||||
|
self.assertEqual(new_part.total_stock, 12345)
|
||||||
|
|
||||||
|
def test_initial_supplier_data(self):
|
||||||
|
"""
|
||||||
|
Tests for initial creation of supplier / manufacturer data
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
|
n = Part.objects.count()
|
||||||
|
|
||||||
|
# Set up initial part data
|
||||||
|
data = {
|
||||||
|
'category': 1,
|
||||||
|
'name': 'Buy Buy Buy',
|
||||||
|
'description': 'A purchaseable part',
|
||||||
|
'purchaseable': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Signal that we wish to create initial supplier data
|
||||||
|
data['add_supplier_info'] = True
|
||||||
|
|
||||||
|
# Specify MPN but not manufacturer
|
||||||
|
data['MPN'] = 'MPN-123'
|
||||||
|
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('manufacturer', response.data)
|
||||||
|
|
||||||
|
# Specify manufacturer but not MPN
|
||||||
|
del data['MPN']
|
||||||
|
data['manufacturer'] = 1
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('MPN', response.data)
|
||||||
|
|
||||||
|
# Specify SKU but not supplier
|
||||||
|
del data['manufacturer']
|
||||||
|
data['SKU'] = 'SKU-123'
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('supplier', response.data)
|
||||||
|
|
||||||
|
# Specify supplier but not SKU
|
||||||
|
del data['SKU']
|
||||||
|
data['supplier'] = 1
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('SKU', response.data)
|
||||||
|
|
||||||
|
# Check that no new parts have been created
|
||||||
|
self.assertEqual(Part.objects.count(), n)
|
||||||
|
|
||||||
|
# Now, fully specify the details
|
||||||
|
data['SKU'] = 'SKU-123'
|
||||||
|
data['supplier'] = 3
|
||||||
|
data['MPN'] = 'MPN-123'
|
||||||
|
data['manufacturer'] = 6
|
||||||
|
|
||||||
|
response = self.post(url, data, expected_code=201)
|
||||||
|
|
||||||
|
self.assertEqual(Part.objects.count(), n + 1)
|
||||||
|
|
||||||
|
pk = response.data['pk']
|
||||||
|
|
||||||
|
new_part = Part.objects.get(pk=pk)
|
||||||
|
|
||||||
|
# Check that there is a new manufacturer part *and* a new supplier part
|
||||||
|
self.assertEqual(new_part.supplier_parts.count(), 1)
|
||||||
|
self.assertEqual(new_part.manufacturer_parts.count(), 1)
|
||||||
|
|
||||||
|
def test_strange_chars(self):
|
||||||
|
"""
|
||||||
|
Test that non-standard ASCII chars are accepted
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
|
name = "Kaltgerätestecker"
|
||||||
|
description = "Gerät"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"category": 2
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.post(url, data, expected_code=201)
|
||||||
|
|
||||||
|
self.assertEqual(response.data['name'], name)
|
||||||
|
self.assertEqual(response.data['description'], description)
|
||||||
|
|
||||||
|
|
||||||
class PartDetailTests(InvenTreeAPITestCase):
|
class PartDetailTests(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user