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