mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-28 22:04:25 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into devOps
This commit is contained in:
@@ -18,6 +18,18 @@ jobs:
|
||||
- name: Check version number
|
||||
run: |
|
||||
python3 ci/check_version_number.py --dev
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
cd docker
|
||||
docker-compose build
|
||||
docker-compose run inventree-dev-server invoke update
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
cd docker
|
||||
docker-compose up -d
|
||||
docker-compose run inventree-dev-server invoke wait
|
||||
docker-compose run inventree-dev-server invoke test
|
||||
docker-compose down
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Test that the InvenTree docker image compiles correctly
|
||||
|
||||
# This CI action runs on pushes to either the master or stable branches
|
||||
|
||||
# 1. Build the development docker image (as per the documentation)
|
||||
# 2. Install requied python libs into the docker container
|
||||
# 3. Launch the container
|
||||
# 4. Check that the API endpoint is available
|
||||
|
||||
name: Docker Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'stable'
|
||||
|
||||
jobs:
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
cd docker
|
||||
docker-compose -f docker-compose.sqlite.yml build
|
||||
docker-compose -f docker-compose.sqlite.yml run inventree-dev-server invoke update
|
||||
docker-compose -f docker-compose.sqlite.yml up -d
|
||||
- name: Sleepy Time
|
||||
run: sleep 60
|
||||
- name: Test API
|
||||
run: |
|
||||
pip install requests
|
||||
python3 ci/check_api_endpoint.py
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue seems stale. Please react to show this is still important.'
|
||||
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
|
||||
stale-issue-label: 'no-activity'
|
||||
stale-pr-label: 'no-activity'
|
||||
stale-issue-label: 'inactive'
|
||||
stale-pr-label: 'inactive'
|
||||
start-date: '2022-01-01'
|
||||
exempt-all-milestones: true
|
||||
|
||||
@@ -16,5 +16,10 @@ jobs:
|
||||
- uses: actions/first-interaction@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-message: 'Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help.\nIf you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/).'
|
||||
pr-message: 'This is your first PR, welcome!\nPlease check [Contributing](https://github.com/inventree/InvenTree/blob/master/CONTRIBUTING.md) to make sure your submission fits our general code-style and workflow.\nMake sure to document why this PR is needed and to link connected issues so we can review it faster.'
|
||||
issue-message: |
|
||||
Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help.
|
||||
If you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/).
|
||||
pr-message: |
|
||||
This is your first PR, welcome!
|
||||
Please check [Contributing](https://github.com/inventree/InvenTree/blob/master/CONTRIBUTING.md) to make sure your submission fits our general code-style and workflow.
|
||||
Make sure to document why this PR is needed and to link connected issues so we can review it faster.
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Main JSON interface views
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
|
||||
@@ -142,6 +142,18 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
|
||||
return response
|
||||
|
||||
def put(self, url, data, expected_code=None, format='json'):
|
||||
"""
|
||||
Issue a PUT request
|
||||
"""
|
||||
|
||||
response = self.client.put(url, data=data, format=format)
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
||||
def options(self, url, expected_code=None):
|
||||
"""
|
||||
Issue an OPTIONS request
|
||||
|
||||
@@ -4,11 +4,19 @@ InvenTree API version information
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 48
|
||||
INVENTREE_API_VERSION = 50
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v50 -> 2022-05-18 : https://github.com/inventree/InvenTree/pull/2912
|
||||
- Implement Attachments for manufacturer parts
|
||||
|
||||
v49 -> 2022-05-09 : https://github.com/inventree/InvenTree/pull/2957
|
||||
- Allows filtering of plugin list by 'active' status
|
||||
- Allows filtering of plugin list by 'mixin' support
|
||||
- Adds endpoint to "identify" or "locate" stock items and locations (using plugins)
|
||||
|
||||
v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977
|
||||
- Adds "export to file" functionality for PurchaseOrder API endpoint
|
||||
- Adds "export to file" functionality for SalesOrder API endpoint
|
||||
|
||||
@@ -131,7 +131,7 @@ class InvenTreeConfig(AppConfig):
|
||||
update = True
|
||||
|
||||
# Backend currency has changed?
|
||||
if not base_currency == backend.base_currency:
|
||||
if base_currency != backend.base_currency:
|
||||
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
|
||||
update = True
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Custom exception handling for the DRF API
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import traceback
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.debug import ExceptionReporter
|
||||
|
||||
from error_report.models import Error
|
||||
|
||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import serializers
|
||||
import rest_framework.views as drfviews
|
||||
|
||||
|
||||
def exception_handler(exc, context):
|
||||
"""
|
||||
Custom exception handler for DRF framework.
|
||||
Ref: https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling
|
||||
|
||||
Catches any errors not natively handled by DRF, and re-throws as an error DRF can handle
|
||||
"""
|
||||
|
||||
response = None
|
||||
|
||||
# Catch any django validation error, and re-throw a DRF validation error
|
||||
if isinstance(exc, DjangoValidationError):
|
||||
exc = DRFValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
# Default to the built-in DRF exception handler
|
||||
response = drfviews.exception_handler(exc, context)
|
||||
|
||||
if response is None:
|
||||
# DRF handler did not provide a default response for this exception
|
||||
|
||||
if settings.DEBUG:
|
||||
error_detail = str(exc)
|
||||
else:
|
||||
error_detail = _("Error details can be found in the admin panel")
|
||||
|
||||
response_data = {
|
||||
'error': type(exc).__name__,
|
||||
'error_class': str(type(exc)),
|
||||
'detail': error_detail,
|
||||
'path': context['request'].path,
|
||||
'status_code': 500,
|
||||
}
|
||||
|
||||
response = Response(response_data, status=500)
|
||||
|
||||
# Log the exception to the database, too
|
||||
kind, info, data = sys.exc_info()
|
||||
|
||||
Error.objects.create(
|
||||
kind=kind.__name__,
|
||||
info=info,
|
||||
data='\n'.join(traceback.format_exception(kind, info, data)),
|
||||
path=context['request'].path,
|
||||
html=ExceptionReporter(context['request'], kind, info, data).get_traceback_html(),
|
||||
)
|
||||
|
||||
if response is not None:
|
||||
# Convert errors returned under the label '__all__' to 'non_field_errors'
|
||||
if '__all__' in response.data:
|
||||
response.data['non_field_errors'] = response.data['__all__']
|
||||
del response.data['__all__']
|
||||
|
||||
return response
|
||||
@@ -1,7 +1,5 @@
|
||||
""" Custom fields used in InvenTree """
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
import sys
|
||||
|
||||
from .validators import allowable_url_schemes
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework.filters import OrderingFilter
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
Helper forms which subclass Django forms to provide additional functionality
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from urllib.parse import urlencode
|
||||
import logging
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ def increment(n):
|
||||
groups = result.groups()
|
||||
|
||||
# If we cannot match the regex, then simply return the provided value
|
||||
if not len(groups) == 2:
|
||||
if len(groups) != 2:
|
||||
return value
|
||||
|
||||
prefix, number = groups
|
||||
@@ -536,7 +536,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
||||
raise ValidationError([_("No serial numbers found")])
|
||||
|
||||
# The number of extracted serial numbers must match the expected quantity
|
||||
if not expected_quantity == len(numbers):
|
||||
if expected_quantity != len(numbers):
|
||||
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
|
||||
|
||||
return numbers
|
||||
@@ -575,7 +575,7 @@ def validateFilterString(value, model=None):
|
||||
|
||||
pair = group.split('=')
|
||||
|
||||
if not len(pair) == 2:
|
||||
if len(pair) != 2:
|
||||
raise ValidationError(
|
||||
"Invalid group: {g}".format(g=group)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from rest_framework import serializers
|
||||
@@ -250,7 +246,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
field_info = super().get_field_info(field)
|
||||
|
||||
# If a default value is specified for the serializer field, add it!
|
||||
if 'default' not in field_info and not field.default == empty:
|
||||
if 'default' not in field_info and field.default != empty:
|
||||
field_info['default'] = field.get_default()
|
||||
|
||||
# Force non-nullable fields to read as "required"
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
Generic models which provide extra functionality over base Django model types.
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
@@ -259,7 +257,7 @@ class InvenTreeAttachment(models.Model):
|
||||
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:
|
||||
if os.path.dirname(new_file) != attachment_dir:
|
||||
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
|
||||
raise ValidationError(_("Invalid attachment directory"))
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
import users.models
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Serializers used in various InvenTree apps
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import tablib
|
||||
|
||||
|
||||
@@ -353,7 +353,7 @@ TEMPLATES = [
|
||||
]
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler',
|
||||
'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler',
|
||||
'DATETIME_FORMAT': '%Y-%m-%d %H:%M',
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
@@ -658,7 +658,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
|
||||
EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', [])
|
||||
|
||||
if not type(EXTRA_URL_SCHEMES) in [list]: # pragma: no cover
|
||||
if type(EXTRA_URL_SCHEMES) not in [list]: # pragma: no cover
|
||||
logger.warning("extra_url_schemes not correctly formatted")
|
||||
EXTRA_URL_SCHEMES = []
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
import json
|
||||
import warnings
|
||||
import requests
|
||||
import logging
|
||||
|
||||
@@ -11,6 +9,8 @@ from django.utils import timezone
|
||||
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.core import mail as django_mail
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
@@ -52,6 +52,15 @@ def schedule_task(taskname, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def raise_warning(msg):
|
||||
"""Log and raise a warning"""
|
||||
logger.warning(msg)
|
||||
|
||||
# If testing is running raise a warning that can be asserted
|
||||
if settings.TESTING:
|
||||
warnings.warn(msg)
|
||||
|
||||
|
||||
def offload_task(taskname, *args, force_sync=False, **kwargs):
|
||||
"""
|
||||
Create an AsyncTask if workers are running.
|
||||
@@ -67,28 +76,38 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
|
||||
|
||||
import importlib
|
||||
from InvenTree.status import is_worker_running
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
logger.warning(f"Could not offload task '{taskname}' - app registry not ready")
|
||||
return
|
||||
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||
raise_warning(f"Could not offload task '{taskname}' - database not ready")
|
||||
|
||||
if is_worker_running() and not force_sync: # pragma: no cover
|
||||
# Running as asynchronous task
|
||||
try:
|
||||
task = AsyncTask(taskname, *args, **kwargs)
|
||||
task.run()
|
||||
except ImportError:
|
||||
logger.warning(f"WARNING: '{taskname}' not started - Function not found")
|
||||
if is_worker_running() and not force_sync: # pragma: no cover
|
||||
# Running as asynchronous task
|
||||
try:
|
||||
task = AsyncTask(taskname, *args, **kwargs)
|
||||
task.run()
|
||||
except ImportError:
|
||||
raise_warning(f"WARNING: '{taskname}' not started - Function not found")
|
||||
else:
|
||||
|
||||
if callable(taskname):
|
||||
# function was passed - use that
|
||||
_func = taskname
|
||||
else:
|
||||
# Split path
|
||||
try:
|
||||
app, mod, func = taskname.split('.')
|
||||
app_mod = app + '.' + mod
|
||||
except ValueError:
|
||||
logger.warning(f"WARNING: '{taskname}' not started - Malformed function path")
|
||||
raise_warning(f"WARNING: '{taskname}' not started - Malformed function path")
|
||||
return
|
||||
|
||||
# Import module from app
|
||||
try:
|
||||
_mod = importlib.import_module(app_mod)
|
||||
except ModuleNotFoundError:
|
||||
logger.warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'")
|
||||
raise_warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'")
|
||||
return
|
||||
|
||||
# Retrieve function
|
||||
@@ -102,17 +121,11 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
|
||||
if not _func:
|
||||
_func = eval(func) # pragma: no cover
|
||||
except NameError:
|
||||
logger.warning(f"WARNING: '{taskname}' not started - No function named '{func}'")
|
||||
raise_warning(f"WARNING: '{taskname}' not started - No function named '{func}'")
|
||||
return
|
||||
|
||||
# Workers are not running: run it as synchronous task
|
||||
_func(*args, **kwargs)
|
||||
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
logger.warning(f"Could not offload task '{taskname}' - app registry not ready")
|
||||
return
|
||||
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||
logger.warning(f"Could not offload task '{taskname}' - database not ready")
|
||||
# Workers are not running: run it as synchronous task
|
||||
_func(*args, **kwargs)
|
||||
|
||||
|
||||
def heartbeat():
|
||||
@@ -126,8 +139,8 @@ def heartbeat():
|
||||
|
||||
try:
|
||||
from django_q.models import Success
|
||||
logger.info("Could not perform heartbeat task - App registry not ready")
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
logger.info("Could not perform heartbeat task - App registry not ready")
|
||||
return
|
||||
|
||||
threshold = timezone.now() - timedelta(minutes=30)
|
||||
@@ -204,26 +217,26 @@ def check_for_updates():
|
||||
|
||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
||||
|
||||
if not response.status_code == 200:
|
||||
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover
|
||||
|
||||
data = json.loads(response.text)
|
||||
|
||||
tag = data.get('tag_name', None)
|
||||
|
||||
if not tag:
|
||||
raise ValueError("'tag_name' missing from GitHub response")
|
||||
raise ValueError("'tag_name' missing from GitHub response") # pragma: no cover
|
||||
|
||||
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag)
|
||||
|
||||
if not len(match.groups()) == 3:
|
||||
if len(match.groups()) != 3: # pragma: no cover
|
||||
logger.warning(f"Version '{tag}' did not match expected pattern")
|
||||
return
|
||||
|
||||
latest_version = [int(x) for x in match.groups()]
|
||||
|
||||
if not len(latest_version) == 3:
|
||||
raise ValueError(f"Version '{tag}' is not correct format")
|
||||
if len(latest_version) != 3:
|
||||
raise ValueError(f"Version '{tag}' is not correct format") # pragma: no cover
|
||||
|
||||
logger.info(f"Latest InvenTree version: '{tag}'")
|
||||
|
||||
@@ -288,7 +301,7 @@ def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||
recipients = [recipients]
|
||||
|
||||
offload_task(
|
||||
'django.core.mail.send_mail',
|
||||
django_mail.send_mail,
|
||||
subject,
|
||||
body,
|
||||
from_email,
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
Unit tests for task management
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from django.test import TestCase
|
||||
from django_q.models import Schedule
|
||||
|
||||
from error_report.models import Error
|
||||
|
||||
import InvenTree.tasks
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
|
||||
threshold = timezone.now() - timedelta(days=30)
|
||||
threshold_low = threshold - timedelta(days=1)
|
||||
|
||||
|
||||
class ScheduledTaskTests(TestCase):
|
||||
@@ -41,3 +51,79 @@ class ScheduledTaskTests(TestCase):
|
||||
# But the 'minutes' should have been updated
|
||||
t = Schedule.objects.get(func=task)
|
||||
self.assertEqual(t.minutes, 5)
|
||||
|
||||
|
||||
def get_result():
|
||||
"""Demo function for test_offloading"""
|
||||
return 'abc'
|
||||
|
||||
|
||||
class InvenTreeTaskTests(TestCase):
|
||||
"""Unit tests for tasks"""
|
||||
|
||||
def test_offloading(self):
|
||||
"""Test task offloading"""
|
||||
|
||||
# Run with function ref
|
||||
InvenTree.tasks.offload_task(get_result)
|
||||
|
||||
# Run with string ref
|
||||
InvenTree.tasks.offload_task('InvenTree.test_tasks.get_result')
|
||||
|
||||
# Error runs
|
||||
# Malformed taskname
|
||||
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree' not started - Malformed function path"):
|
||||
InvenTree.tasks.offload_task('InvenTree')
|
||||
|
||||
# Non exsistent app
|
||||
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTreeABC.test_tasks.doesnotmatter' not started - No module named 'InvenTreeABC.test_tasks'"):
|
||||
InvenTree.tasks.offload_task('InvenTreeABC.test_tasks.doesnotmatter')
|
||||
|
||||
# Non exsistent function
|
||||
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree.test_tasks.doesnotexsist' not started - No function named 'doesnotexsist'"):
|
||||
InvenTree.tasks.offload_task('InvenTree.test_tasks.doesnotexsist')
|
||||
|
||||
def test_task_hearbeat(self):
|
||||
"""Test the task heartbeat"""
|
||||
InvenTree.tasks.offload_task(InvenTree.tasks.heartbeat)
|
||||
|
||||
def test_task_delete_successful_tasks(self):
|
||||
"""Test the task delete_successful_tasks"""
|
||||
from django_q.models import Success
|
||||
|
||||
Success.objects.create(name='abc', func='abc', stopped=threshold, started=threshold_low)
|
||||
InvenTree.tasks.offload_task(InvenTree.tasks.delete_successful_tasks)
|
||||
results = Success.objects.filter(started__lte=threshold)
|
||||
self.assertEqual(len(results), 0)
|
||||
|
||||
def test_task_delete_old_error_logs(self):
|
||||
"""Test the task delete_old_error_logs"""
|
||||
|
||||
# Create error
|
||||
error_obj = Error.objects.create()
|
||||
error_obj.when = threshold_low
|
||||
error_obj.save()
|
||||
|
||||
# Check that it is not empty
|
||||
errors = Error.objects.filter(when__lte=threshold,)
|
||||
self.assertNotEqual(len(errors), 0)
|
||||
|
||||
# Run action
|
||||
InvenTree.tasks.offload_task(InvenTree.tasks.delete_old_error_logs)
|
||||
|
||||
# Check that it is empty again
|
||||
errors = Error.objects.filter(when__lte=threshold,)
|
||||
self.assertEqual(len(errors), 0)
|
||||
|
||||
def test_task_check_for_updates(self):
|
||||
"""Test the task check_for_updates"""
|
||||
# Check that setting should be empty
|
||||
self.assertEqual(InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION'), '')
|
||||
|
||||
# Get new version
|
||||
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_updates)
|
||||
|
||||
# Check that setting is not empty
|
||||
response = InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION')
|
||||
self.assertNotEqual(response, '')
|
||||
self.assertTrue(bool(response))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
from test.support import EnvironmentVarGuard
|
||||
import os
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
import django.core.exceptions as django_exceptions
|
||||
@@ -449,17 +451,20 @@ class TestSettings(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user_mdl = get_user_model()
|
||||
self.env = EnvironmentVarGuard()
|
||||
|
||||
# Create a user for auth
|
||||
user = get_user_model()
|
||||
self.user = user.objects.create_superuser('testuser1', 'test1@testing.com', 'password1')
|
||||
self.client.login(username='testuser1', password='password1')
|
||||
|
||||
def run_reload(self):
|
||||
def in_env_context(self, envs={}):
|
||||
"""Patch the env to include the given dict"""
|
||||
return mock.patch.dict(os.environ, envs)
|
||||
|
||||
def run_reload(self, envs={}):
|
||||
from plugin import registry
|
||||
|
||||
with self.env:
|
||||
with self.in_env_context(envs):
|
||||
settings.USER_ADDED = False
|
||||
registry.reload_plugins()
|
||||
|
||||
@@ -475,25 +480,28 @@ class TestSettings(TestCase):
|
||||
self.assertEqual(user_count(), 1)
|
||||
|
||||
# not enough set
|
||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
||||
self.run_reload()
|
||||
self.run_reload({
|
||||
'INVENTREE_ADMIN_USER': 'admin'
|
||||
})
|
||||
self.assertEqual(user_count(), 1)
|
||||
|
||||
# enough set
|
||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
||||
self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email
|
||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password
|
||||
self.run_reload()
|
||||
self.run_reload({
|
||||
'INVENTREE_ADMIN_USER': 'admin', # set username
|
||||
'INVENTREE_ADMIN_EMAIL': 'info@example.com', # set email
|
||||
'INVENTREE_ADMIN_PASSWORD': 'password123' # set password
|
||||
})
|
||||
self.assertEqual(user_count(), 2)
|
||||
|
||||
# create user manually
|
||||
self.user_mdl.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
self.assertEqual(user_count(), 3)
|
||||
# check it will not be created again
|
||||
self.env.set('INVENTREE_ADMIN_USER', 'testuser')
|
||||
self.env.set('INVENTREE_ADMIN_EMAIL', 'test@testing.com')
|
||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password')
|
||||
self.run_reload()
|
||||
self.run_reload({
|
||||
'INVENTREE_ADMIN_USER': 'testuser',
|
||||
'INVENTREE_ADMIN_EMAIL': 'test@testing.com',
|
||||
'INVENTREE_ADMIN_PASSWORD': 'password',
|
||||
})
|
||||
self.assertEqual(user_count(), 3)
|
||||
|
||||
# make sure to clean up
|
||||
@@ -517,20 +525,30 @@ class TestSettings(TestCase):
|
||||
|
||||
def test_helpers_cfg_file(self):
|
||||
# normal run - not configured
|
||||
self.assertIn('InvenTree/InvenTree/config.yaml', config.get_config_file())
|
||||
|
||||
valid = [
|
||||
'inventree/config.yaml',
|
||||
'inventree/dev/config.yaml',
|
||||
]
|
||||
|
||||
self.assertTrue(any([opt in config.get_config_file().lower() for opt in valid]))
|
||||
|
||||
# with env set
|
||||
with self.env:
|
||||
self.env.set('INVENTREE_CONFIG_FILE', 'my_special_conf.yaml')
|
||||
self.assertIn('InvenTree/InvenTree/my_special_conf.yaml', config.get_config_file())
|
||||
with self.in_env_context({'INVENTREE_CONFIG_FILE': 'my_special_conf.yaml'}):
|
||||
self.assertIn('inventree/inventree/my_special_conf.yaml', config.get_config_file().lower())
|
||||
|
||||
def test_helpers_plugin_file(self):
|
||||
# normal run - not configured
|
||||
self.assertIn('InvenTree/InvenTree/plugins.txt', config.get_plugin_file())
|
||||
|
||||
valid = [
|
||||
'inventree/plugins.txt',
|
||||
'inventree/dev/plugins.txt',
|
||||
]
|
||||
|
||||
self.assertTrue(any([opt in config.get_plugin_file().lower() for opt in valid]))
|
||||
|
||||
# with env set
|
||||
with self.env:
|
||||
self.env.set('INVENTREE_PLUGIN_FILE', 'my_special_plugins.txt')
|
||||
with self.in_env_context({'INVENTREE_PLUGIN_FILE': 'my_special_plugins.txt'}):
|
||||
self.assertIn('my_special_plugins.txt', config.get_plugin_file())
|
||||
|
||||
def test_helpers_setting(self):
|
||||
@@ -539,8 +557,7 @@ class TestSettings(TestCase):
|
||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, '123!'), '123!')
|
||||
|
||||
# with env set
|
||||
with self.env:
|
||||
self.env.set(TEST_ENV_NAME, '321')
|
||||
with self.in_env_context({TEST_ENV_NAME: '321'}):
|
||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321')
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ In particular these views provide base functionality for rendering Django forms
|
||||
as JSON objects and passing them to modal forms (using jQuery / bootstrap).
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
import os
|
||||
import json
|
||||
|
||||
@@ -627,7 +625,7 @@ class SetPasswordView(AjaxUpdateView):
|
||||
if valid:
|
||||
# Passwords must match
|
||||
|
||||
if not p1 == p2:
|
||||
if p1 != p2:
|
||||
error = _('Password fields must match')
|
||||
form.add_error('enter_password', error)
|
||||
form.add_error('confirm_password', error)
|
||||
@@ -797,13 +795,9 @@ class CurrencyRefreshView(RedirectView):
|
||||
On a POST request we will attempt to refresh the exchange rates
|
||||
"""
|
||||
|
||||
from InvenTree.tasks import offload_task
|
||||
from InvenTree.tasks import offload_task, update_exchange_rates
|
||||
|
||||
# Define associated task from InvenTree.tasks list of methods
|
||||
taskname = 'InvenTree.tasks.update_exchange_rates'
|
||||
|
||||
# Run it
|
||||
offload_task(taskname, force_sync=True)
|
||||
offload_task(update_exchange_rates, force_sync=True)
|
||||
|
||||
return redirect(reverse_lazy('settings'))
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
JSON API for the Build app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.urls import include, re_path
|
||||
|
||||
from rest_framework import filters, generics
|
||||
@@ -285,6 +282,13 @@ class BuildOutputDelete(BuildOrderContextMixin, generics.CreateAPIView):
|
||||
API endpoint for deleting multiple build outputs
|
||||
"""
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
ctx['to_complete'] = False
|
||||
|
||||
return ctx
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = build.serializers.BuildOutputDeleteSerializer
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
Django Forms for interacting with Build objects
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
@@ -2,8 +2,6 @@
|
||||
Build database model definitions
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
import decimal
|
||||
|
||||
import os
|
||||
@@ -777,7 +775,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
if not output.is_building:
|
||||
raise ValidationError(_("Build output is already completed"))
|
||||
|
||||
if not output.build == self:
|
||||
if output.build != self:
|
||||
raise ValidationError(_("Build output does not match Build Order"))
|
||||
|
||||
# Unallocate all build items against the output
|
||||
@@ -1141,12 +1139,13 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
||||
"""
|
||||
Callback function to be executed after a Build instance is saved
|
||||
"""
|
||||
from . import tasks as build_tasks
|
||||
|
||||
if created:
|
||||
# A new Build has just been created
|
||||
|
||||
# Run checks on required parts
|
||||
InvenTree.tasks.offload_task('build.tasks.check_build_stock', instance)
|
||||
InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance)
|
||||
|
||||
|
||||
class BuildOrderAttachment(InvenTreeAttachment):
|
||||
@@ -1240,7 +1239,7 @@ class BuildItem(models.Model):
|
||||
})
|
||||
|
||||
# Quantity must be 1 for serialized stock
|
||||
if self.stock_item.serialized and not self.quantity == 1:
|
||||
if self.stock_item.serialized and self.quantity != 1:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must be 1 for serialized stock')
|
||||
})
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
JSON serializers for Build API
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -202,7 +199,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
|
||||
if quantity < 0:
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_("Quantity must be greater than zero"))
|
||||
|
||||
part = self.get_part()
|
||||
@@ -212,7 +209,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
if part.trackable:
|
||||
raise ValidationError(_("Integer quantity required for trackable parts"))
|
||||
|
||||
if part.has_trackable_parts():
|
||||
if part.has_trackable_parts:
|
||||
raise ValidationError(_("Integer quantity required, as the bill of materials contains trackable parts"))
|
||||
|
||||
return quantity
|
||||
@@ -235,7 +232,6 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
|
||||
serial_numbers = serial_numbers.strip()
|
||||
|
||||
# TODO: Field level validation necessary here?
|
||||
return serial_numbers
|
||||
|
||||
auto_allocate = serializers.BooleanField(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
|
||||
+209
-3
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.urls import reverse
|
||||
@@ -305,6 +302,215 @@ class BuildTest(BuildAPITest):
|
||||
|
||||
self.assertEqual(bo.status, BuildStatus.CANCELLED)
|
||||
|
||||
def test_create_delete_output(self):
|
||||
"""
|
||||
Test that we can create and delete build outputs via the API
|
||||
"""
|
||||
|
||||
bo = Build.objects.get(pk=1)
|
||||
|
||||
n_outputs = bo.output_count
|
||||
|
||||
create_url = reverse('api-build-output-create', kwargs={'pk': 1})
|
||||
|
||||
# Attempt to create outputs with invalid data
|
||||
response = self.post(
|
||||
create_url,
|
||||
{
|
||||
'quantity': 'not a number',
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('A valid number is required', str(response.data))
|
||||
|
||||
for q in [-100, -10.3, 0]:
|
||||
|
||||
response = self.post(
|
||||
create_url,
|
||||
{
|
||||
'quantity': q,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
if q == 0:
|
||||
self.assertIn('Quantity must be greater than zero', str(response.data))
|
||||
else:
|
||||
self.assertIn('Ensure this value is greater than or equal to 0', str(response.data))
|
||||
|
||||
# Mark the part being built as 'trackable' (requires integer quantity)
|
||||
bo.part.trackable = True
|
||||
bo.part.save()
|
||||
|
||||
response = self.post(
|
||||
create_url,
|
||||
{
|
||||
'quantity': 12.3,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Integer quantity required for trackable parts', str(response.data))
|
||||
|
||||
# Erroneous serial numbers
|
||||
response = self.post(
|
||||
create_url,
|
||||
{
|
||||
'quantity': 5,
|
||||
'serial_numbers': '1, 2, 3, 4, 5, 6',
|
||||
'batch': 'my-batch',
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Number of unique serial numbers (6) must match quantity (5)', str(response.data))
|
||||
|
||||
# At this point, no new build outputs should have been created
|
||||
self.assertEqual(n_outputs, bo.output_count)
|
||||
|
||||
# Now, create with *good* data
|
||||
response = self.post(
|
||||
create_url,
|
||||
{
|
||||
'quantity': 5,
|
||||
'serial_numbers': '1, 2, 3, 4, 5',
|
||||
'batch': 'my-batch',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# 5 new outputs have been created
|
||||
self.assertEqual(n_outputs + 5, bo.output_count)
|
||||
|
||||
# Attempt to create with identical serial numbers
|
||||
response = self.post(
|
||||
create_url,
|
||||
{
|
||||
'quantity': 3,
|
||||
'serial_numbers': '1-3',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('The following serial numbers already exist : 1,2,3', str(response.data))
|
||||
|
||||
# Double check no new outputs have been created
|
||||
self.assertEqual(n_outputs + 5, bo.output_count)
|
||||
|
||||
# Now, let's delete each build output individually via the API
|
||||
outputs = bo.build_outputs.all()
|
||||
|
||||
delete_url = reverse('api-build-output-delete', kwargs={'pk': 1})
|
||||
|
||||
response = self.post(
|
||||
delete_url,
|
||||
{
|
||||
'outputs': [],
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('A list of build outputs must be provided', str(response.data))
|
||||
|
||||
# Mark 1 build output as complete
|
||||
bo.complete_build_output(outputs[0], self.user)
|
||||
|
||||
self.assertEqual(n_outputs + 5, bo.output_count)
|
||||
self.assertEqual(1, bo.complete_count)
|
||||
|
||||
# Delete all outputs at once
|
||||
# Note: One has been completed, so this should fail!
|
||||
response = self.post(
|
||||
delete_url,
|
||||
{
|
||||
'outputs': [
|
||||
{
|
||||
'output': output.pk,
|
||||
} for output in outputs
|
||||
]
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('This build output has already been completed', str(response.data))
|
||||
|
||||
# No change to the build outputs
|
||||
self.assertEqual(n_outputs + 5, bo.output_count)
|
||||
self.assertEqual(1, bo.complete_count)
|
||||
|
||||
# Let's delete 2 build outputs
|
||||
response = self.post(
|
||||
delete_url,
|
||||
{
|
||||
'outputs': [
|
||||
{
|
||||
'output': output.pk,
|
||||
} for output in outputs[1:3]
|
||||
]
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
# Two build outputs have been removed
|
||||
self.assertEqual(n_outputs + 3, bo.output_count)
|
||||
self.assertEqual(1, bo.complete_count)
|
||||
|
||||
# Tests for BuildOutputComplete serializer
|
||||
complete_url = reverse('api-build-output-complete', kwargs={'pk': 1})
|
||||
|
||||
# Let's mark the remaining outputs as complete
|
||||
response = self.post(
|
||||
complete_url,
|
||||
{
|
||||
'outputs': [],
|
||||
'location': 4,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('A list of build outputs must be provided', str(response.data))
|
||||
|
||||
for output in outputs[3:]:
|
||||
output.refresh_from_db()
|
||||
self.assertTrue(output.is_building)
|
||||
|
||||
response = self.post(
|
||||
complete_url,
|
||||
{
|
||||
'outputs': [
|
||||
{
|
||||
'output': output.pk
|
||||
} for output in outputs[3:]
|
||||
],
|
||||
'location': 4,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# Check that the outputs have been completed
|
||||
self.assertEqual(3, bo.complete_count)
|
||||
|
||||
for output in outputs[3:]:
|
||||
output.refresh_from_db()
|
||||
self.assertEqual(output.location.pk, 4)
|
||||
self.assertFalse(output.is_building)
|
||||
|
||||
# Try again, with an output which has already been completed
|
||||
response = self.post(
|
||||
complete_url,
|
||||
{
|
||||
'outputs': [
|
||||
{
|
||||
'output': outputs.last().pk,
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('This build output has already been completed', str(response.data))
|
||||
|
||||
|
||||
class BuildAllocationTest(BuildAPITest):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Django views for interacting with Build objects
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Provides a JSON API for common components.
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
|
||||
from django.http.response import HttpResponse
|
||||
|
||||
@@ -199,7 +199,7 @@ class FileManager:
|
||||
|
||||
try:
|
||||
# Excel import casts number-looking-items into floats, which is annoying
|
||||
if item == int(item) and not str(item) == str(int(item)):
|
||||
if item == int(item) and str(item) != str(int(item)):
|
||||
data[idx] = int(item)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Django forms for interacting with common objects
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ Common database model definitions.
|
||||
These models are 'generic' and do not fit a particular business logic object.
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import decimal
|
||||
import math
|
||||
@@ -1802,10 +1799,8 @@ class WebhookEndpoint(models.Model):
|
||||
def process_webhook(self):
|
||||
if self.token:
|
||||
self.verify = VerificationMethod.TOKEN
|
||||
# TODO make a object-setting
|
||||
if self.secret:
|
||||
self.verify = VerificationMethod.HMAC
|
||||
# TODO make a object-setting
|
||||
return True
|
||||
|
||||
def validate_token(self, payload, headers, request):
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
JSON serializers for common components
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from InvenTree.helpers import get_objectreference
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
User-configurable settings for the common app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from common.notifications import NotificationMethod, SingleNotificationMethod, BulkNotificationMethod, storage
|
||||
from plugin.models import NotificationUserSetting
|
||||
from part.test_part import BaseNotificationIntegrationTest
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from common.models import NotificationEntry
|
||||
from . import tasks as common_tasks
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
|
||||
@@ -14,4 +15,4 @@ class TaskTest(TestCase):
|
||||
|
||||
# check empty run
|
||||
self.assertEqual(NotificationEntry.objects.all().count(), 0)
|
||||
offload_task('common.tasks.delete_old_notifications',)
|
||||
offload_task(common_tasks.delete_old_notifications,)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
"""
|
||||
Unit tests for the views associated with the 'common' app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
from datetime import timedelta
|
||||
@@ -133,7 +132,7 @@ class SettingsTest(TestCase):
|
||||
if description is None:
|
||||
raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover
|
||||
|
||||
if not key == key.upper():
|
||||
if key != key.upper():
|
||||
raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover
|
||||
|
||||
def test_defaults(self):
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Django views for interacting with common models
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
@@ -11,7 +8,7 @@ import import_export.widgets as widgets
|
||||
from .models import Company
|
||||
from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
|
||||
|
||||
from part.models import Part
|
||||
|
||||
@@ -112,6 +109,16 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
|
||||
autocomplete_fields = ('part', 'manufacturer',)
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
|
||||
"""
|
||||
Admin class for ManufacturerPartAttachment model
|
||||
"""
|
||||
|
||||
list_display = ('manufacturer_part', 'attachment', 'comment')
|
||||
|
||||
autocomplete_fields = ('manufacturer_part',)
|
||||
|
||||
|
||||
class ManufacturerPartParameterResource(ModelResource):
|
||||
"""
|
||||
Class for managing ManufacturerPartParameter data import/export
|
||||
@@ -178,4 +185,5 @@ admin.site.register(SupplierPart, SupplierPartAdmin)
|
||||
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
||||
|
||||
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
|
||||
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
|
||||
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Provides a JSON API for the Company app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters import rest_framework as rest_filters
|
||||
|
||||
@@ -15,13 +12,14 @@ from django.urls import include, re_path
|
||||
from django.db.models import Q
|
||||
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.api import AttachmentMixin
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
|
||||
from .models import SupplierPart, SupplierPriceBreak
|
||||
|
||||
from .serializers import CompanySerializer
|
||||
from .serializers import ManufacturerPartSerializer, ManufacturerPartParameterSerializer
|
||||
from .serializers import ManufacturerPartSerializer, ManufacturerPartAttachmentSerializer, ManufacturerPartParameterSerializer
|
||||
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
|
||||
|
||||
|
||||
@@ -163,6 +161,32 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = ManufacturerPartSerializer
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload).
|
||||
"""
|
||||
|
||||
queryset = ManufacturerPartAttachment.objects.all()
|
||||
serializer_class = ManufacturerPartAttachmentSerializer
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
'manufacturer_part',
|
||||
]
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
Detail endpooint for ManufacturerPartAttachment model
|
||||
"""
|
||||
|
||||
queryset = ManufacturerPartAttachment.objects.all()
|
||||
serializer_class = ManufacturerPartAttachmentSerializer
|
||||
|
||||
|
||||
class ManufacturerPartParameterList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for list view of ManufacturerPartParamater model.
|
||||
@@ -390,6 +414,12 @@ class SupplierPriceBreakDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
manufacturer_part_api_urls = [
|
||||
|
||||
# Base URL for ManufacturerPartAttachment API endpoints
|
||||
re_path(r'^attachment/', include([
|
||||
re_path(r'^(?P<pk>\d+)/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'),
|
||||
re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'),
|
||||
])),
|
||||
|
||||
re_path(r'^parameter/', include([
|
||||
re_path(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Django Forms for interacting with Company app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 3.2.13 on 2022-05-01 12:57
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('company', '0042_supplierpricebreak_updated'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ManufacturerPartAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
|
||||
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
|
||||
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
|
||||
('manufacturer_part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='company.manufacturerpart', verbose_name='Manufacturer Part')),
|
||||
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -2,9 +2,6 @@
|
||||
Company database model definitions
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -25,6 +22,7 @@ from stdimage.models import StdImageField
|
||||
|
||||
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
from InvenTree.models import InvenTreeAttachment
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
|
||||
import InvenTree.validators
|
||||
@@ -383,6 +381,22 @@ class ManufacturerPart(models.Model):
|
||||
return s
|
||||
|
||||
|
||||
class ManufacturerPartAttachment(InvenTreeAttachment):
|
||||
"""
|
||||
Model for storing file attachments against a ManufacturerPart object
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
return reverse('api-manufacturer-part-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
return os.path.join("manufacturer_part_files", str(self.manufacturer_part.id))
|
||||
|
||||
manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE,
|
||||
verbose_name=_('Manufacturer Part'), related_name='attachments')
|
||||
|
||||
|
||||
class ManufacturerPartParameter(models.Model):
|
||||
"""
|
||||
A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
|
||||
@@ -494,7 +508,7 @@ class SupplierPart(models.Model):
|
||||
# Ensure that the linked manufacturer_part points to the same part!
|
||||
if self.manufacturer_part and self.part:
|
||||
|
||||
if not self.manufacturer_part.part == self.part:
|
||||
if self.manufacturer_part.part != self.part:
|
||||
raise ValidationError({
|
||||
'manufacturer_part': _("Linked manufacturer part must reference the same base part"),
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ from rest_framework import serializers
|
||||
|
||||
from sql_util.utils import SubqueryCount
|
||||
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializer
|
||||
from InvenTree.serializers import InvenTreeDecimalField
|
||||
from InvenTree.serializers import InvenTreeImageSerializerField
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
@@ -16,7 +17,7 @@ from InvenTree.serializers import InvenTreeMoneySerializer
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
|
||||
from .models import SupplierPart, SupplierPriceBreak
|
||||
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
@@ -142,6 +143,29 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""
|
||||
Serializer for the ManufacturerPartAttachment class
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = ManufacturerPartAttachment
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'manufacturer_part',
|
||||
'attachment',
|
||||
'filename',
|
||||
'link',
|
||||
'comment',
|
||||
'upload_date',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'upload_date',
|
||||
]
|
||||
|
||||
|
||||
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for the ManufacturerPartParameter model
|
||||
|
||||
@@ -144,6 +144,21 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-attachments'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Attachments" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "attachment_button.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% include "attachment_table.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-parameters'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
@@ -178,6 +193,34 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
onPanelLoad("attachments", function() {
|
||||
loadAttachmentTable('{% url "api-manufacturer-part-attachment-list" %}', {
|
||||
filters: {
|
||||
manufacturer_part: {{ part.pk }},
|
||||
},
|
||||
fields: {
|
||||
manufacturer_part: {
|
||||
value: {{ part.pk }},
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-manufacturer-part-attachment-list" %}',
|
||||
{
|
||||
data: {
|
||||
manufacturer_part: {{ part.id }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
reloadAttachmentTable();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function reloadParameters() {
|
||||
$("#parameter-table").bootstrapTable("refresh");
|
||||
}
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
|
||||
{% trans "Parameters" as text %}
|
||||
{% include "sidebar_item.html" with label='parameters' text=text icon="fa-th-list" %}
|
||||
{% trans "Attachments" as text %}
|
||||
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
|
||||
{% trans "Supplier Parts" as text %}
|
||||
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}
|
||||
@@ -1,8 +1,5 @@
|
||||
""" Unit tests for Company views (see views.py) """
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
Django views for interacting with Company app
|
||||
"""
|
||||
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
@@ -162,7 +158,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
|
||||
self.response = response
|
||||
|
||||
# Check for valid response code
|
||||
if not response.status_code == 200:
|
||||
if response.status_code != 200:
|
||||
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
|
||||
return
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
@@ -24,6 +21,7 @@ from plugin.registry import registry
|
||||
|
||||
from stock.models import StockItem, StockLocation
|
||||
from part.models import Part
|
||||
from plugin.base.label import label as plugin_label
|
||||
|
||||
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
||||
from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer
|
||||
@@ -156,7 +154,7 @@ class LabelPrintMixin:
|
||||
|
||||
# Offload a background task to print the provided label
|
||||
offload_task(
|
||||
'plugin.base.label.label.print_label',
|
||||
plugin_label.print_label,
|
||||
plugin.plugin_slug(),
|
||||
image,
|
||||
label_instance=label_instance,
|
||||
|
||||
@@ -105,7 +105,7 @@ class LabelConfig(AppConfig):
|
||||
# File already exists - let's see if it is the "same",
|
||||
# or if we need to overwrite it with a newer copy!
|
||||
|
||||
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
||||
if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
|
||||
logger.info(f"Hash differs for '{filename}'")
|
||||
to_copy = True
|
||||
|
||||
@@ -199,7 +199,7 @@ class LabelConfig(AppConfig):
|
||||
# File already exists - let's see if it is the "same",
|
||||
# or if we need to overwrite it with a newer copy!
|
||||
|
||||
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
||||
if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
|
||||
logger.info(f"Hash differs for '{filename}'")
|
||||
to_copy = True
|
||||
|
||||
@@ -291,7 +291,7 @@ class LabelConfig(AppConfig):
|
||||
if os.path.exists(dst_file):
|
||||
# File already exists - let's see if it is the "same"
|
||||
|
||||
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
||||
if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
|
||||
logger.info(f"Hash differs for '{filename}'")
|
||||
to_copy = True
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Label printing models
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Tests for labels
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Tests for labels
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1470
-1455
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1292
-1265
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1369
-1354
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1358
-1343
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1338
-1323
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
+30
-7
@@ -2,9 +2,6 @@
|
||||
JSON API for the Order app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.urls import include, path, re_path
|
||||
from django.db.models import Q, F
|
||||
|
||||
@@ -27,6 +24,8 @@ import order.serializers as serializers
|
||||
from part.models import Part
|
||||
from users.models import Owner
|
||||
|
||||
from plugin.serializers import MetadataSerializer
|
||||
|
||||
|
||||
class GeneralExtraLineList:
|
||||
"""
|
||||
@@ -347,6 +346,15 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||
serializer_class = serializers.PurchaseOrderIssueSerializer
|
||||
|
||||
|
||||
class PurchaseOrderMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for viewing / updating PurchaseOrder metadata"""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
return MetadataSerializer(models.PurchaseOrder, *args, **kwargs)
|
||||
|
||||
queryset = models.PurchaseOrder.objects.all()
|
||||
|
||||
|
||||
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to receive stock items against a purchase order.
|
||||
@@ -916,6 +924,15 @@ class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
|
||||
serializer_class = serializers.SalesOrderCompleteSerializer
|
||||
|
||||
|
||||
class SalesOrderMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for viewing / updating SalesOrder metadata"""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
return MetadataSerializer(models.SalesOrder, *args, **kwargs)
|
||||
|
||||
queryset = models.SalesOrder.objects.all()
|
||||
|
||||
|
||||
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocation stock items against a SalesOrder,
|
||||
@@ -1138,10 +1155,13 @@ order_api_urls = [
|
||||
|
||||
# Individual purchase order detail URLs
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
|
||||
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
|
||||
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
|
||||
re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
|
||||
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
|
||||
re_path(r'^metadata/', PurchaseOrderMetadata.as_view(), name='api-po-metadata'),
|
||||
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
|
||||
|
||||
# PurchaseOrder detail API endpoint
|
||||
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
|
||||
])),
|
||||
|
||||
@@ -1178,10 +1198,13 @@ order_api_urls = [
|
||||
|
||||
# Sales order detail view
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
|
||||
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
|
||||
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
||||
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
|
||||
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
|
||||
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
|
||||
re_path(r'^metadata/', SalesOrderMetadata.as_view(), name='api-so-metadata'),
|
||||
|
||||
# SalesOrder detail endpoint
|
||||
re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'),
|
||||
])),
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Django Forms for interacting with Order objects
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.13 on 2022-05-16 11:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0066_alter_purchaseorder_supplier'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchaseorder',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesorder',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.13 on 2022-05-16 14:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0067_auto_20220516_1120'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='salesorderallocation',
|
||||
unique_together=set(),
|
||||
),
|
||||
]
|
||||
@@ -30,7 +30,9 @@ from users import models as UserModels
|
||||
from part import models as PartModels
|
||||
from stock import models as stock_models
|
||||
from company.models import Company, SupplierPart
|
||||
|
||||
from plugin.events import trigger_event
|
||||
from plugin.models import MetadataMixin
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
||||
@@ -97,7 +99,7 @@ def get_next_so_number():
|
||||
return reference
|
||||
|
||||
|
||||
class Order(ReferenceIndexingMixin):
|
||||
class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||
""" Abstract model for an order.
|
||||
|
||||
Instances of this class:
|
||||
@@ -306,7 +308,7 @@ class PurchaseOrder(Order):
|
||||
except ValueError:
|
||||
raise ValidationError({'quantity': _("Invalid quantity provided")})
|
||||
|
||||
if not supplier_part.supplier == self.supplier:
|
||||
if supplier_part.supplier != self.supplier:
|
||||
raise ValidationError({'supplier': _("Part supplier must match PO supplier")})
|
||||
|
||||
if group:
|
||||
@@ -445,7 +447,7 @@ class PurchaseOrder(Order):
|
||||
if barcode is None:
|
||||
barcode = ''
|
||||
|
||||
if not self.status == PurchaseOrderStatus.PLACED:
|
||||
if self.status != PurchaseOrderStatus.PLACED:
|
||||
raise ValidationError(
|
||||
"Lines can only be received against an order marked as 'PLACED'"
|
||||
)
|
||||
@@ -729,7 +731,7 @@ class SalesOrder(Order):
|
||||
Return True if this order can be cancelled
|
||||
"""
|
||||
|
||||
if not self.status == SalesOrderStatus.PENDING:
|
||||
if self.status != SalesOrderStatus.PENDING:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -1267,12 +1269,6 @@ class SalesOrderAllocation(models.Model):
|
||||
def get_api_url():
|
||||
return reverse('api-so-allocation-list')
|
||||
|
||||
class Meta:
|
||||
unique_together = [
|
||||
# Cannot allocate any given StockItem to the same line more than once
|
||||
('line', 'item'),
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validate the SalesOrderAllocation object:
|
||||
@@ -1295,7 +1291,7 @@ class SalesOrderAllocation(models.Model):
|
||||
raise ValidationError({'item': _('Stock item has not been assigned')})
|
||||
|
||||
try:
|
||||
if not self.line.part == self.item.part:
|
||||
if self.line.part != self.item.part:
|
||||
errors['item'] = _('Cannot allocate stock item to a line with a different part')
|
||||
except PartModels.Part.DoesNotExist:
|
||||
errors['line'] = _('Cannot allocate stock to a line without a part')
|
||||
@@ -1310,7 +1306,7 @@ class SalesOrderAllocation(models.Model):
|
||||
if self.quantity <= 0:
|
||||
errors['quantity'] = _('Allocation quantity must be greater than zero')
|
||||
|
||||
if self.item.serial and not self.quantity == 1:
|
||||
if self.item.serial and self.quantity != 1:
|
||||
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
|
||||
|
||||
if self.line.order != self.shipment.order:
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
JSON serializers for the Order API
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -1287,14 +1284,18 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
||||
|
||||
with transaction.atomic():
|
||||
for entry in items:
|
||||
|
||||
# Create a new SalesOrderAllocation
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
allocation = order.models.SalesOrderAllocation(
|
||||
line=entry.get('line_item'),
|
||||
item=entry.get('stock_item'),
|
||||
quantity=entry.get('quantity'),
|
||||
shipment=shipment,
|
||||
)
|
||||
|
||||
allocation.full_clean()
|
||||
allocation.save()
|
||||
|
||||
|
||||
class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
|
||||
""" Serializer for a SalesOrderExtraLine object """
|
||||
|
||||
@@ -306,6 +306,22 @@ class PurchaseOrderTest(OrderTest):
|
||||
|
||||
self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
|
||||
|
||||
def test_po_metadata(self):
|
||||
url = reverse('api-po-metadata', kwargs={'pk': 1})
|
||||
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
'metadata': {
|
||||
'yam': 'yum',
|
||||
}
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
order = models.PurchaseOrder.objects.get(pk=1)
|
||||
self.assertEqual(order.get_metadata('yam'), 'yum')
|
||||
|
||||
|
||||
class PurchaseOrderReceiveTest(OrderTest):
|
||||
"""
|
||||
@@ -875,6 +891,22 @@ class SalesOrderTest(OrderTest):
|
||||
|
||||
self.assertEqual(so.status, SalesOrderStatus.CANCELLED)
|
||||
|
||||
def test_so_metadata(self):
|
||||
url = reverse('api-so-metadata', kwargs={'pk': 1})
|
||||
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
'metadata': {
|
||||
'xyz': 'abc',
|
||||
}
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
order = models.SalesOrder.objects.get(pk=1)
|
||||
self.assertEqual(order.get_metadata('xyz'), 'abc')
|
||||
|
||||
|
||||
class SalesOrderAllocateTest(OrderTest):
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
""" Unit tests for Order views (see views.py) """
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Django views for interacting with Order app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db.utils import IntegrityError
|
||||
from django.http.response import JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user