Compare commits

...

67 Commits

Author SHA1 Message Date
github-actions[bot] 57e9497da1
Prevent settings from writing to the database during import (#5844) (#5845)
- Fixes conflicts which can occur due to importing duplicate settings from a data file

(cherry picked from commit 29a191e05e)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-11-03 12:15:59 +11:00
Oliver a7c0882c02
Fix exchange rate updates (#5843)
- Handle base currencies which are not USD
- API changed "base_currency" to "base"
2023-11-03 11:58:39 +11:00
Oliver 5d55555394
Wrap inventreeBranch check in try/except (#5842)
- Same as in master branch
2023-11-03 11:35:21 +11:00
Oliver d2a313bda9
Handle ValueError when creating stock item (#5751) 2023-10-19 11:46:32 +11:00
Oliver e366cd1865
Update version.py
Bump version number to 0.12.9
2023-10-19 10:48:27 +11:00
github-actions[bot] 86542bc561
Fix location priority when receiving items (#5661) (#5663)
* Fix location priority when receiving items

Fixes POReceiveBarcodeHandler

* Unit test adjustment

(cherry picked from commit 4b13f3b0de)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-10-05 00:33:09 +11:00
Oliver a7487ff842
Backport of https://github.com/inventree/InvenTree/pull/5632 (#5650)
Co-authored-by: Matthias Mair <code@mjmair.com>
2023-10-03 12:55:15 +11:00
Oliver 5725a9e271
Fix for readthedocs build config file (#5651) 2023-10-03 12:28:14 +11:00
Oliver fe9a56a5c1
Allow stocktake quantity to be zero (#5644)
- Backport of https://github.com/inventree/InvenTree/pull/5627/

Co-authored-by: Oliver Lippert <oliver@allesit.de>
2023-10-02 22:49:10 +11:00
Oliver 39e682cd45
Correctly extract error information if plugin install fails (#5638) 2023-10-01 14:29:08 +11:00
github-actions[bot] a36ab0c004
Fix bug in loading setting from cache (#5612) (#5616)
* Fix bug in loading setting from cache

- Need to bring out "cache" key first, otherwise it gets included in the calculated cache key
- Reference: https://github.com/inventree/InvenTree/issues/5586

* Unit test fixes

(cherry picked from commit 87a8755e1b)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-09-25 16:05:17 +10:00
github-actions[bot] 0b45d6f236
Use prefetch to improve query efficiency (#5613) (#5614)
- Significantly improves performance of users.models.update_group_roles() function
- Ref: https://github.com/inventree/InvenTree/pull/5612

(cherry picked from commit 200bc5bd3d)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-09-25 13:35:13 +10:00
github-actions[bot] 978e08f3a3
stop cascade update_pricing if prices didnt change (#5606) (#5607)
Co-authored-by: Oliver Lippert <oliver@lipperts-web.de>
(cherry picked from commit 2f0dbf9776)

Co-authored-by: Oliver Lippert <oliver@allesit.de>
2023-09-24 23:06:03 +10:00
github-actions[bot] 85b8157611
Primary address fix (#5592) (#5598)
* Improve management of primary address for a company

- Simplify approach (remove "confirm_primary" field)
- Remove @receiver hook
- Move all logic into Address.save() method

* Make address primary if it is the only one defined for a company

* Update frontend table

* Fix saving logic

* Actually fix it this time

* Fix for unit test

* Another test fix

(cherry picked from commit 324d5929b5)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-09-23 10:25:12 +10:00
github-actions[bot] aaabce9873
Fix set password when no old password exists (#5562) (#5563)
(cherry picked from commit fe68598c1b)

Co-authored-by: Firas Ben Mefteh <ben.meft0@gmail.com>
2023-09-18 20:58:17 +10:00
Oliver f5a36ce44e
Update version.py (#5553)
Bump to 0.12.8
2023-09-18 13:35:33 +10:00
Oliver 6563b4c413
Pin setuptools version for docker CI (#5552)
* Pin setuptools version for docker CI

* Fix doc links
2023-09-16 12:17:43 +10:00
github-actions[bot] abed9fb284
Update required parts for build orders (#5542) (#5543)
- When a BomItem is created or edited, update any active build orders which use it
- Runs as a background task
- Fixes https://github.com/inventree/InvenTree/issues/5541

(cherry picked from commit c8021ec319)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-09-15 10:06:39 +10:00
Lukas 09872eec8e
[0.12.x] Fix missing filters for get settings validator (#5480) (#5522)
* Fix missing filters for get settings validator (#5480)

* Fix missing filters for get settings validator

* merge default model instance filters and kwargs

* Added tests for validators

* Give it a try without the kwargs passed to clean in save function

* Added string for identification for debug statement

* Added more debug comments

* Added more debug prints

* Fix test debug

* Modiefied workflow

* trigger ci

* Fix test and remove unused kwargs

* Added debug prints

* Only run one test in ci

* Added more debug code

* Remove all debug prints and reset workflow

* Reset overlooked file

(cherry picked from commit 9a6c2d2953)

# Conflicts:
#	InvenTree/plugin/samples/integration/test_sample.py

* Add missing import

* Added second missing import
2023-09-11 10:57:42 +10:00
github-actions[bot] 099b837a4e
Login form fix (#5502) (#5504)
* Handle login without supplier user

- Use custom login form
- Redirect back to login page
- No longer throws error

* Fix method return

(cherry picked from commit 71ad4a1c99)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-09-05 12:43:39 +10:00
Oliver cf977ad29a
Update version.py (#5474)
Bump version to 0.12.7
2023-08-24 18:51:20 +10:00
github-actions[bot] 72464c50cc
Url fix (#5472) (#5473)
* Use urljoin function to construct absolute URL

* Add unit test

(cherry picked from commit 8da5d62c69)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-08-24 16:04:42 +10:00
Oliver 942bc5350d
Bump version to 0.12.6 (#5465)
- Skipping 0.12.5 due to an error on the last release
2023-08-23 12:22:32 +10:00
github-actions[bot] 7876676114
Fix plugin pickeling (#5412) (#5457)
(cherry picked from commit 1fe382e318)

Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
2023-08-17 21:04:23 +10:00
Oliver ea039645c3
Update unit tests (#5446)
- Remove failing test which no longer applies
2023-08-14 16:39:00 +10:00
github-actions[bot] b5c7cf0779
Fix html tag in template (#5445) (#5448)
- Ensure <td> tag is closed correctly

(cherry picked from commit e7b5b145bf)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-08-14 16:28:58 +10:00
github-actions[bot] 89d8e47bab
Disable "add rate" button in Admin interface (#5444) (#5447)
- Does not work with custom backend
- Throws error if the button is pressed
- So, remove the button

(cherry picked from commit a2f614ad41)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-08-14 16:19:13 +10:00
github-actions[bot] b8e726d8a4
Catch IndexError when importing data (#5439) (#5443)
* Catch IndexError when importing data

* Also handle TypeError

(cherry picked from commit 93e4dadb49)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-08-14 15:39:07 +10:00
github-actions[bot] 3b238fdbba
Fix for potential NoReverseMatch error (#5440) (#5442)
- Check that the database model really does exist in the template code

(cherry picked from commit a8118ed406)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-08-14 15:21:29 +10:00
github-actions[bot] df8c2692a0
Fix build output unallocate button (#5426) (#5427)
(cherry picked from commit dce565b4a3)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-08-11 10:13:52 +10:00
Oliver 7391f33a97
Do not enforce units for part parameters (#5423)
Backport of #5160
2023-08-10 21:13:46 +10:00
Oliver b1158f7083
Bump version number to 0.12.5 (#5424) 2023-08-10 21:13:37 +10:00
github-actions[bot] 4969628150
Purchase history graph fix (#5421) (#5422)
* Fix debug messages

* Fix bug in purchase history chart

- Use new pack_quantity_native attribute

(cherry picked from commit 86ca0b27a4)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-08-10 14:33:37 +10:00
Oliver 57eada1da1
backport email fix (#5409)
- Backport of https://github.com/inventree/InvenTree/pull/5396
2023-08-08 15:19:00 +10:00
Oliver f526dcdeec
fix cli on 22.04 (#5204) (#5395)
* fix cli on 22.04 (#5204)

(cherry picked from commit d4fad4f5c8)

* Update weasyprint docs link

* Another link fix

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
2023-08-03 16:21:53 +10:00
github-actions[bot] aacf35ed47
Improve sorting of part column for BOM table (#5386) (#5387)
(cherry picked from commit c39ae80a13)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-08-02 17:43:08 +10:00
github-actions[bot] ca986cba01
Fix auto-allocation of build outputs (#5378) (#5379)
- Creation of BuildItem objects was using old model references

(cherry picked from commit 668dab4175)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-08-01 11:26:10 +10:00
github-actions[bot] 699fb83dd4
Fix SSO check comparing id against name and extend log output (#5340) (#5377)
* add error log on SSO check failure

* sso_check_provider: fix by comparing against id

the name is the pretty printed version which not necessarily is the same
as the provider id it is compared against. This fails e.g. for the
microsoft allauth extension where the id is microsoft, but the name is
"Microsoft Graph".

Closes: #5330
(cherry picked from commit ee5416719f)

Co-authored-by: Hendrik v. Raven <hendrik@consetetur.de>
2023-08-01 10:39:46 +10:00
Oliver dd6e225cda
Update version.py (#5374)
Bump version number to 0.12.4
2023-07-31 12:45:49 +10:00
github-actions[bot] 1f3a49b1ae
Fix for migration - updating from old version (#5372) (#5373)
(cherry picked from commit 90383ccb53)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-31 12:43:20 +10:00
github-actions[bot] 385e7cb478
Return 404 on API requests other than GET (#5365) (#5366)
- Other request methods need love too!

(cherry picked from commit 59ffdcaa19)
(cherry picked from commit b89a120f9e)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-28 22:14:55 +10:00
github-actions[bot] 73768bfee1
Handle purchase price export for .xls files (#5362) (#5363)
(cherry picked from commit 87da286f2f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-28 15:36:34 +10:00
github-actions[bot] 946fe2df29
Handle errors when printing reports (#5360) (#5361)
- Re-throw as a ValidationError
- Results in a 400 error, not a 500

(cherry picked from commit 5f3d3b28b3)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-28 14:23:43 +10:00
github-actions[bot] afa7ed873f
Exclude some common fields from django-import-export (#5349) (#5351)
- Add "get_fields()" method to InvenTreeResource
- Override default behaviour and exclude some common fields
- Will flow down to any inheriting classes

(cherry picked from commit 941451203a)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-26 17:22:21 +10:00
github-actions[bot] 46da332afe
Allow duplicate BOM items when duplicating a part (#5347) (#5350)
(cherry picked from commit 6660508326)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-26 16:54:20 +10:00
Oliver 072b7b3146
Update version.py
Bump version number to 0.12.3
2023-07-25 11:46:41 +10:00
github-actions[bot] 1d51b2a058
Email config fix (#5336) (#5338)
* Change for DEFAULT_FROM_EMAIL

- Use USERNAME if not specified

(cherry picked from commit 487ac917c90e9fe3da4effaa9326b707ceecd321)

* Email configuration fails if DEFAULT_FROM_EMAIL not set

(cherry picked from commit 01e573c3a2702e7c21ed13b0cb44280c89d3dee1)

* Docs update

(cherry picked from commit bfedb9cf87)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-25 11:45:17 +10:00
github-actions[bot] 08f9bebdf0
Fix admin url to point to right model (#5319) (#5321)
(cherry picked from commit 9b377ccfbf)

Co-authored-by: Marcel Pörner <me@nerade.de>
2023-07-23 22:38:27 +10:00
github-actions[bot] 6d6629f11c
Stock installed table fix (#5305) (#5306)
* Prevent installed items from being hidden

* Fix parent / child relationship

(cherry picked from commit f70294b247)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-21 23:57:00 +10:00
github-actions[bot] db88fbda11
Fix company index page title (#5288) (#5291)
(cherry picked from commit 3baa640d70)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-20 10:07:39 +10:00
github-actions[bot] 49c9b5b1aa
Docker build: Update python deps (#5270) (#5271)
* Update python deps

* Update requirements.in

* Fix requirements-dev.txt

(cherry picked from commit b717011f06)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-18 20:11:29 +10:00
github-actions[bot] e1a0e79ead
Fix settings function callback (#5259) (#5262)
* fix settings function callback

* merge instance filters and passed keys

(cherry picked from commit df77305d60)

Co-authored-by: Matthias Mair <code@mjmair.com>
2023-07-17 20:23:44 +10:00
github-actions[bot] ab22f2a04d
Fix language code for pt-br (#5256) (#5257)
- Has to be lowercase in settings.py to work correctly

(cherry picked from commit 20b59c3575)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-16 19:37:27 +10:00
github-actions[bot] 8a58bf5ffa
Only update theme if value provided (#5240) (#5241)
- Handles case where null or invalid value provided

(cherry picked from commit 41167f22c9)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-13 20:39:28 +10:00
Oliver 6730098bac
Update version.py (#5238)
Bump version number to 0.12.2
2023-07-13 15:13:47 +10:00
github-actions[bot] 93b44ad8e6
fix typo (#5236) (#5237)
(cherry picked from commit bd1689095d)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-13 11:03:28 +10:00
github-actions[bot] 9b5e828b87
Protected settings fix (#5229) (#5231)
* Hide protected setting in settings view

* Implement custom serializer for setting value

- Return '***' if the setting is protected

* Implement to_internal_value

* Stringify

* Add protected setting to sample plugin

* Unit tests for plugin settings API

* Update unit test

(cherry picked from commit 01f2aa5f74)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-12 16:31:28 +10:00
github-actions[bot] cf5d637678
Add missing callback for attachment delete button (#5219) (#5220)
(cherry picked from commit b3dcc28bd9)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-11 11:30:57 +10:00
github-actions[bot] feb2acf668
Fix link to SalesOrder in stock history table (#5210) (#5211)
(cherry picked from commit 8fb7612894)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-10 13:23:09 +10:00
Oliver 0017570dd3
Bump version number to 0.12.1 (#5201) 2023-07-07 14:25:30 +10:00
github-actions[bot] 4c41a50bb1
Fix allocation check for completing build order (#5199) (#5200)
- Allocation check only applies to untracked line items

(cherry picked from commit 1f81daadf6)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-07 13:48:18 +10:00
github-actions[bot] eab3fdcf2c
Fix quantity aggregation for stock table (#5188) (#5190)
* Fix quantity aggregation for stock table

- Stock quantity can only be added together if units are the same

* Add stock total footer to part table

(cherry picked from commit 773dd3b210)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-06 12:55:22 +10:00
github-actions[bot] c59eee7359
Param fix (#5183) (#5184)
* Handle AttributeError in convert_physical_value

* Added new unit test

(cherry picked from commit 9abcc0ec34)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-06 11:11:27 +10:00
github-actions[bot] 4a5ebf8f01
Handle exception when creating default labels (#5163) (#5166)
* Handle exception when creating default labels

- Running workers in parallel may cause race conditions
- Catch any exception which is raised

* Prevent password from being logged

* Update default timeout for docker

(cherry picked from commit 8b730884d7)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-04 22:54:21 +10:00
github-actions[bot] 698798fee7
Order table improvements (#5151) (#5152)
- prevent "double loading" of order tables

(cherry picked from commit 17c2070503)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-04 16:23:43 +10:00
github-actions[bot] 2660889879
Rendering fix for build allocation table (#5145) (#5149)
- Fix link to part
- Fix link to stock item

(cherry picked from commit 5f61b5f120)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2023-07-04 13:40:40 +10:00
github-actions[bot] 01aaf95a0e
fix: add missing build model property (#5127) (#5132)
* fix: add missing virtual build property

* chore: improve docstring

(cherry picked from commit 2e7c86ff92)

Co-authored-by: Mark Oude Elberink <mark@oude-elberink.de>
2023-07-03 10:04:02 +10:00
69 changed files with 871 additions and 350 deletions

View File

@ -60,7 +60,7 @@ jobs:
docker-compose run inventree-dev-server invoke update
docker-compose run inventree-dev-server invoke setup-dev
docker-compose up -d
docker-compose run inventree-dev-server pip install --upgrade setuptools
docker-compose run inventree-dev-server pip install setuptools==68.1.2
docker-compose run inventree-dev-server invoke wait
- name: Check Data Directory
# The following file structure should have been created by the docker image

View File

@ -1,5 +1,10 @@
"""Admin classes"""
from django.contrib import admin
from django.http.request import HttpRequest
from djmoney.contrib.exchange.admin import RateAdmin
from djmoney.contrib.exchange.models import Rate
from import_export.resources import ModelResource
@ -31,3 +36,27 @@ class InvenTreeResource(ModelResource):
row[idx] = val
return row
def get_fields(self, **kwargs):
"""Return fields, with some common exclusions"""
fields = super().get_fields(**kwargs)
fields_to_exclude = [
'metadata',
'lft', 'rght', 'tree_id', 'level',
]
return [f for f in fields if f.column_name not in fields_to_exclude]
class CustomRateAdmin(RateAdmin):
"""Admin interface for the Rate class"""
def has_add_permission(self, request: HttpRequest) -> bool:
"""Disable the 'add' permission for Rate objects"""
return False
admin.site.unregister(Rate)
admin.site.register(Rate, CustomRateAdmin)

View File

@ -59,14 +59,39 @@ class NotFoundView(AjaxView):
permission_classes = [permissions.AllowAny]
def get(self, request, *args, **kwargs):
"""Process an `not found` event on the API."""
data = {
'details': _('API endpoint not found'),
'url': request.build_absolute_uri(),
}
def not_found(self, request):
"""Return a 404 error"""
return JsonResponse(
{
'detail': _('API endpoint not found'),
'url': request.build_absolute_uri(),
},
status=404
)
return JsonResponse(data, status=404)
def options(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def get(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def post(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def patch(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def put(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def delete(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
class BulkDeleteMixin:

View File

@ -195,8 +195,8 @@ class InvenTreeConfig(AppConfig):
else:
new_user = user.objects.create_superuser(add_user, add_email, add_password)
logger.info(f'User {str(new_user)} was created!')
except IntegrityError as _e:
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}')
except IntegrityError:
logger.warning(f'The user "{add_user}" could not be created')
# do not try again
settings.USER_ADDED = True

View File

@ -91,7 +91,7 @@ def convert_physical_value(value: str, unit: str = None):
# At this point we *should* have a valid pint value
# To double check, look at the maginitude
float(val.magnitude)
except (TypeError, ValueError):
except (TypeError, ValueError, AttributeError):
error = _('Provided value is not a valid number')
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError):
error = _('Provided value has an invalid unit')

View File

@ -17,6 +17,7 @@ def is_email_configured():
NOTE: This does not check if the configuration is valid!
"""
configured = True
testing = settings.TESTING
if InvenTree.ready.isInTestMode():
return False
@ -28,17 +29,24 @@ def is_email_configured():
configured = False
# Display warning unless in test mode
if not settings.TESTING: # pragma: no cover
if not testing: # pragma: no cover
logger.debug("EMAIL_HOST is not configured")
# Display warning unless in test mode
if not settings.EMAIL_HOST_USER and not settings.TESTING: # pragma: no cover
if not settings.EMAIL_HOST_USER and not testing: # pragma: no cover
logger.debug("EMAIL_HOST_USER is not configured")
# Display warning unless in test mode
if not settings.EMAIL_HOST_PASSWORD and not settings.TESTING: # pragma: no cover
if not settings.EMAIL_HOST_PASSWORD and testing: # pragma: no cover
logger.debug("EMAIL_HOST_PASSWORD is not configured")
# Email sender must be configured
if not settings.DEFAULT_FROM_EMAIL:
configured = False
if not testing: # pragma: no cover
logger.warning("DEFAULT_FROM_EMAIL is not configured")
return configured

View File

@ -1,12 +1,11 @@
"""Exchangerate backend to use `exchangerate.host` to get rates."""
"""Exchangerate backend to use `frankfurter.app` to get rates."""
import ssl
from decimal import Decimal
from urllib.error import URLError
from urllib.request import urlopen
from django.db.utils import OperationalError
import certifi
import requests
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
from common.settings import currency_code_default, currency_codes
@ -15,19 +14,19 @@ from common.settings import currency_code_default, currency_codes
class InvenTreeExchange(SimpleExchangeBackend):
"""Backend for automatically updating currency exchange rates.
Uses the `exchangerate.host` service API
Uses the `frankfurter.app` service API
"""
name = "InvenTreeExchange"
def __init__(self):
"""Set API url."""
self.url = "https://api.exchangerate.host/latest"
self.url = "https://api.frankfurter.app/latest"
super().__init__()
def get_params(self):
"""Placeholder to set API key. Currently not required by `exchangerate.host`."""
"""Placeholder to set API key. Currently not required by `frankfurter.app`."""
# No API key is required
return {
}
@ -40,14 +39,23 @@ class InvenTreeExchange(SimpleExchangeBackend):
url = self.get_url(**kwargs)
try:
context = ssl.create_default_context(cafile=certifi.where())
response = urlopen(url, timeout=5, context=context)
return response.read()
response = requests.get(url=url, timeout=5)
return response.content
except Exception:
# Something has gone wrong, but we can just try again next time
# Raise a TypeError so the outer function can handle this
raise TypeError
def get_rates(self, **params):
"""Intersect the requested currency codes with the available codes."""
rates = super().get_rates(**params)
# Add the base currency to the rates
base_currency = params.get('base', currency_code_default())
rates[base_currency] = Decimal("1.0")
return rates
def update_rates(self, base_currency=None):
"""Set the requested currency codes and get rates."""
# Set default - see B008

View File

@ -12,7 +12,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from allauth.account.adapter import DefaultAccountAdapter
from allauth.account.forms import SignupForm, set_form_field_order
from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth_2fa.adapter import OTPAdapter
@ -161,11 +161,30 @@ class SetPasswordForm(HelperForm):
old_password = forms.CharField(
label=_("Old password"),
strip=False,
required=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}),
)
# override allauth
class CustomLoginForm(LoginForm):
"""Custom login form to override default allauth behaviour"""
def login(self, request, redirect_url=None):
"""Perform login action.
First check that:
- A valid user has been supplied
"""
if not self.user:
# No user supplied - redirect to the login page
return HttpResponseRedirect(reverse('account_login'))
# Now perform default login action
return super().login(request, redirect_url)
class CustomSignupForm(SignupForm):
"""Override to use dynamic settings."""
@ -292,6 +311,15 @@ class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, OTPAdapter, Default
return False
def get_email_confirmation_url(self, request, emailconfirmation):
"""Construct the email confirmation url"""
from InvenTree.helpers_model import construct_absolute_url
url = super().get_email_confirmation_url(request, emailconfirmation)
url = construct_absolute_url(url)
return url
class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocialAccountAdapter):
"""Override of adapter to use dynamic settings."""

View File

@ -3,6 +3,7 @@
import io
import logging
from decimal import Decimal
from urllib.parse import urljoin
from django.conf import settings
from django.core.validators import URLValidator
@ -50,9 +51,7 @@ def construct_absolute_url(*arg, **kwargs):
# Otherwise, try to use the InvenTree setting
try:
site_url = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL', create=False, cache=False)
except ProgrammingError:
pass
except OperationalError:
except (ProgrammingError, OperationalError):
pass
if not site_url:
@ -66,14 +65,7 @@ def construct_absolute_url(*arg, **kwargs):
# No site URL available, return the relative URL
return relative_url
# Strip trailing slash from base url
if site_url.endswith('/'):
site_url = site_url[:-1]
if relative_url.startswith('/'):
relative_url = relative_url[1:]
return f"{site_url}/{relative_url}"
return urljoin(site_url, relative_url)
def get_base_url(**kwargs):

View File

@ -601,6 +601,8 @@ DATABASES = {
REMOTE_LOGIN = get_boolean_setting('INVENTREE_REMOTE_LOGIN', 'remote_login_enabled', False)
REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', 'remote_login_header', 'REMOTE_USER')
LOGIN_REDIRECT_URL = "/index/"
# sentry.io integration for error reporting
SENTRY_ENABLED = get_boolean_setting('INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False)
@ -757,14 +759,14 @@ LANGUAGES = [
('no', _('Norwegian')),
('pl', _('Polish')),
('pt', _('Portuguese')),
('pt-BR', _('Portuguese (Brazilian)')),
('pt-br', _('Portuguese (Brazilian)')),
('ru', _('Russian')),
('sl', _('Slovenian')),
('sv', _('Swedish')),
('th', _('Thai')),
('tr', _('Turkish')),
('vi', _('Vietnamese')),
('zh-hans', _('Chinese')),
('zh-hans', _('Chinese (Simplified)')),
]
# Testing interface translations
@ -822,6 +824,10 @@ EMAIL_USE_SSL = get_boolean_setting('INVENTREE_EMAIL_SSL', 'email.ssl', False)
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_SENDER', 'email.sender', '')
# If "from" email not specified, default to the username
if not DEFAULT_FROM_EMAIL:
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_USERNAME', 'email.username', '')
EMAIL_USE_LOCALTIME = False
EMAIL_TIMEOUT = 60
@ -871,7 +877,7 @@ ACCOUNT_PREVENT_ENUMERATION = True
# override forms / adapters
ACCOUNT_FORMS = {
'login': 'allauth.account.forms.LoginForm',
'login': 'InvenTree.forms.CustomLoginForm',
'signup': 'InvenTree.forms.CustomSignupForm',
'add_email': 'allauth.account.forms.AddEmailForm',
'change_password': 'allauth.account.forms.ChangePasswordForm',

View File

@ -56,6 +56,23 @@ class ConversionTest(TestCase):
q = InvenTree.conversion.convert_physical_value(val).to_base_units()
self.assertEqual(q.magnitude, expected)
def test_invalid_values(self):
"""Test conversion of invalid inputs"""
inputs = [
'-',
';;',
'-x',
'?',
'--',
'+',
'++',
]
for val in inputs:
with self.assertRaises(ValidationError):
InvenTree.conversion.convert_physical_value(val)
class ValidatorTest(TestCase):
"""Simple tests for custom field validators."""
@ -216,6 +233,34 @@ class FormatTest(TestCase):
class TestHelpers(TestCase):
"""Tests for InvenTree helper functions."""
def test_absolute_url(self):
"""Test helper function for generating an absolute URL"""
base = "https://demo.inventree.org:12345"
InvenTreeSetting.set_setting('INVENTREE_BASE_URL', base, change_user=None)
tests = {
"": base,
"api/": base + "/api/",
"/api/": base + "/api/",
"api": base + "/api",
"media/label/output/": base + "/media/label/output/",
"static/logo.png": base + "/static/logo.png",
"https://www.google.com": "https://www.google.com",
"https://demo.inventree.org:12345/out.html": "https://demo.inventree.org:12345/out.html",
"https://demo.inventree.org/test.html": "https://demo.inventree.org/test.html",
"http://www.cwi.nl:80/%7Eguido/Python.html": "http://www.cwi.nl:80/%7Eguido/Python.html",
"test.org": base + "/test.org",
}
for url, expected in tests.items():
# Test with supplied base URL
self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url, site_url=base), expected)
# Test without supplied base URL
self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url), expected)
def test_image_url(self):
"""Test if a filename looks like an image."""
for name in ['ape.png', 'bat.GiF', 'apple.WeBP', 'BiTMap.Bmp']:
@ -700,6 +745,7 @@ class CurrencyTests(TestCase):
else: # pragma: no cover
print("Exchange rate update failed - retrying")
print(f'Expected {currency_codes()}, got {[a.currency for a in rates]}')
time.sleep(1)
self.assertTrue(update_successful)

View File

@ -9,7 +9,8 @@ from django.contrib import admin
from django.urls import include, path, re_path
from django.views.generic.base import RedirectView
from dj_rest_auth.registration.views import (SocialAccountDisconnectView,
from dj_rest_auth.registration.views import (ConfirmEmailView,
SocialAccountDisconnectView,
SocialAccountListView)
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
@ -74,13 +75,16 @@ apipatterns = [
# InvenTree information endpoint
path('', InfoView.as_view(), name='api-inventree-info'),
# Third party API endpoints
path('auth/', include('dj_rest_auth.urls')),
path('auth/registration/', include('dj_rest_auth.registration.urls')),
path('auth/providers/', SocialProvierListView.as_view(), name='social_providers'),
path('auth/social/', include(social_auth_urlpatterns)),
path('auth/social/', SocialAccountListView.as_view(), name='social_account_list'),
path('auth/social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),
# Auth API endpoints
path('auth/', include([
re_path(r'^registration/account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailView.as_view(), name='account_confirm_email'),
path('registration/', include('dj_rest_auth.registration.urls')),
path('providers/', SocialProvierListView.as_view(), name='social_providers'),
path('social/', include(social_auth_urlpatterns)),
path('social/', SocialAccountListView.as_view(), name='social_account_list'),
path('social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),
path('', include('dj_rest_auth.urls')),
])),
# Unknown endpoint
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),

View File

@ -18,7 +18,7 @@ from dulwich.repo import NotGitRepository, Repo
from .api_version import INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = "0.12.0"
INVENTREE_SW_VERSION = "0.12.9"
# Discover git
try:
@ -162,8 +162,11 @@ def inventreeBranch():
if main_commit is None:
return None
branch = main_repo.refs.follow(b'HEAD')[0][1].decode()
return branch.removeprefix('refs/heads/')
try:
branch = main_repo.refs.follow(b'HEAD')[0][1].decode()
return branch.removeprefix('refs/heads/')
except IndexError:
return None # pragma: no cover
def inventreeTarget():

View File

@ -447,8 +447,7 @@ class SetPasswordView(AjaxUpdateView):
if valid:
# Old password must be correct
if not user.check_password(old_password):
if user.has_usable_password() and not user.check_password(old_password):
form.add_error('old_password', _('Wrong password provided'))
valid = False
@ -640,8 +639,12 @@ class AppearanceSelectView(RedirectView):
user_theme = common_models.ColorTheme()
user_theme.user = request.user
user_theme.name = theme
user_theme.save()
if theme:
try:
user_theme.name = theme
user_theme.save()
except Exception:
pass
return redirect(reverse_lazy('settings'))

View File

@ -361,6 +361,11 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
return self.build_lines.filter(bom_item__sub_part__trackable=False)
@property
def are_untracked_parts_allocated(self):
"""Returns True if all untracked parts are allocated for this BuildOrder."""
return self.is_fully_allocated(tracked=False)
def has_untracked_line_items(self):
"""Returns True if this BuildOrder has non trackable BomItems."""
return self.has_untracked_line_items.count() > 0
@ -714,14 +719,22 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
if items.exists() and items.count() == 1:
stock_item = items[0]
# Allocate the stock item
BuildItem.objects.create(
build=self,
bom_item=bom_item,
stock_item=stock_item,
quantity=1,
install_into=output,
)
# Find the 'BuildLine' object which points to this BomItem
try:
build_line = BuildLine.objects.get(
build=self,
bom_item=bom_item
)
# Allocate the stock items against the BuildLine
BuildItem.objects.create(
build_line=build_line,
stock_item=stock_item,
quantity=1,
install_into=output,
)
except BuildLine.DoesNotExist:
pass
else:
"""Create a single build output of the given quantity."""
@ -1173,7 +1186,8 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
BuildLine.objects.bulk_create(lines)
logger.info(f"Created {len(lines)} BuildLine objects for BuildOrder")
if len(lines) > 0:
logger.info(f"Created {len(lines)} BuildLine objects for BuildOrder")
@transaction.atomic
def update_build_line_items(self):

View File

@ -630,7 +630,7 @@ class BuildCompleteSerializer(serializers.Serializer):
return {
'overallocated': build.is_overallocated(),
'allocated': build.is_fully_allocated(),
'allocated': build.are_untracked_parts_allocated,
'remaining': build.remaining,
'incomplete': build.incomplete_count,
}
@ -663,7 +663,7 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Check if the 'accept_unallocated' field is required"""
build = self.context['build']
if not build.is_fully_allocated() and not value:
if not build.are_untracked_parts_allocated and not value:
raise ValidationError(_('Required stock has not been fully allocated'))
return value

View File

@ -24,6 +24,55 @@ import part.models as part_models
logger = logging.getLogger('inventree')
def update_build_order_lines(bom_item_pk: int):
"""Update all BuildOrderLineItem objects which reference a particular BomItem.
This task is triggered when a BomItem is created or updated.
"""
logger.info(f"Updating build order lines for BomItem {bom_item_pk}")
bom_item = part_models.BomItem.objects.filter(pk=bom_item_pk).first()
# If the BomItem has been deleted, there is nothing to do
if not bom_item:
return
assemblies = bom_item.get_assemblies()
# Find all active builds which reference any of the parts
builds = build.models.Build.objects.filter(
part__in=list(assemblies),
status__in=BuildStatusGroups.ACTIVE_CODES
)
# Iterate through each build, and update the relevant line items
for bo in builds:
# Try to find a matching build order line
line = build.models.BuildLine.objects.filter(
build=bo,
bom_item=bom_item,
).first()
q = bom_item.get_required_quantity(bo.quantity)
if line:
# Ensure quantity is correct
if line.quantity != q:
line.quantity = q
line.save()
else:
# Create a new line item
build.models.BuildLine.objects.create(
build=bo,
bom_item=bom_item,
quantity=q,
)
if builds.count() > 0:
logger.info(f"Updated {builds.count()} build orders for part {bom_item.part}")
def check_build_stock(build: build.models.Build):
"""Check the required stock for a newly created build order.

View File

@ -146,6 +146,7 @@ class BuildTest(BuildTestBase):
def test_ref_int(self):
"""Test the "integer reference" field used for natural sorting"""
# Set build reference to new value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
refs = {
@ -168,6 +169,9 @@ class BuildTest(BuildTestBase):
build.save()
self.assertEqual(build.reference_int, ref_int)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_ref_validation(self):
"""Test that the reference field validation works as expected"""
@ -214,6 +218,9 @@ class BuildTest(BuildTestBase):
title='Valid reference',
)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_next_ref(self):
"""Test that the next reference is automatically generated"""
@ -238,6 +245,9 @@ class BuildTest(BuildTestBase):
self.assertEqual(build.reference, 'XYZ-000988')
self.assertEqual(build.reference_int, 988)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_init(self):
"""Perform some basic tests before we start the ball rolling"""

View File

@ -166,7 +166,7 @@ class BaseInvenTreeSetting(models.Model):
do_cache = kwargs.pop('cache', True)
self.clean(**kwargs)
self.clean()
self.validate_unique()
# Execute before_save action
@ -190,7 +190,7 @@ class BaseInvenTreeSetting(models.Model):
kwargs: Keyword arguments to pass to the function
"""
# Get action
setting = self.get_setting_definition(self.key, *args, **kwargs)
setting = self.get_setting_definition(self.key, *args, **{**self.get_filters_for_instance(), **kwargs})
settings_fnc = setting.get(reference, None)
# Execute if callable
@ -406,6 +406,17 @@ class BaseInvenTreeSetting(models.Model):
**cls.get_filters(**kwargs),
}
# Unless otherwise specified, attempt to create the setting
create = kwargs.pop('create', True)
# Prevent saving to the database during data import
if InvenTree.ready.isImportingData():
create = False
# Prevent saving to the database during migrations
if InvenTree.ready.isRunningMigrations():
create = False
# Perform cache lookup by default
do_cache = kwargs.pop('cache', True)
@ -434,9 +445,6 @@ class BaseInvenTreeSetting(models.Model):
# Setting does not exist! (Try to create it)
if not setting:
# Unless otherwise specified, attempt to create the setting
create = kwargs.pop('create', True)
# Prevent creation of new settings objects when importing data
if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True, allow_shell=True):
create = False
@ -560,7 +568,7 @@ class BaseInvenTreeSetting(models.Model):
"""Return units for setting."""
return self.__class__.get_setting_units(self.key, **self.get_filters_for_instance())
def clean(self, **kwargs):
def clean(self):
"""If a validator (or multiple validators) are defined for a particular setting key, run them against the 'value' field."""
super().clean()
@ -571,7 +579,7 @@ class BaseInvenTreeSetting(models.Model):
elif self.is_bool():
self.value = self.as_bool()
validator = self.__class__.get_setting_validator(self.key, **kwargs)
validator = self.__class__.get_setting_validator(self.key, **self.get_filters_for_instance())
if validator is not None:
self.run_validator(validator)

View File

@ -13,6 +13,25 @@ from InvenTree.serializers import (InvenTreeImageSerializerField,
InvenTreeModelSerializer)
class SettingsValueField(serializers.Field):
"""Custom serializer field for a settings value."""
def get_attribute(self, instance):
"""Return the object instance, not the attribute value."""
return instance
def to_representation(self, instance):
"""Return the value of the setting:
- Protected settings are returned as '***'
"""
return '***' if instance.protected else str(instance.value)
def to_internal_value(self, data):
"""Return the internal value of the setting"""
return str(data)
class SettingsSerializer(InvenTreeModelSerializer):
"""Base serializer for a settings object."""
@ -30,6 +49,8 @@ class SettingsSerializer(InvenTreeModelSerializer):
api_url = serializers.CharField(read_only=True)
value = SettingsValueField()
def get_choices(self, obj):
"""Returns the choices available for a given item."""
results = []
@ -45,16 +66,6 @@ class SettingsSerializer(InvenTreeModelSerializer):
return results
def get_value(self, obj):
"""Make sure protected values are not returned."""
# never return protected values
if obj.protected:
result = '***'
else:
result = obj.value
return result
class GlobalSettingsSerializer(SettingsSerializer):
"""Serializer for the InvenTreeSetting model."""

View File

@ -15,7 +15,7 @@ def currency_code_default():
from common.models import InvenTreeSetting
try:
code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', create=False, cache=False)
code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True)
except Exception: # pragma: no cover
# Database may not yet be ready, no need to throw an error here
code = ''

View File

@ -5,9 +5,11 @@ import json
import time
from datetime import timedelta
from http import HTTPStatus
from unittest import mock
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
@ -106,6 +108,36 @@ class SettingsTest(InvenTreeTestCase):
self.assertIn('STOCK_OWNERSHIP_CONTROL', result)
self.assertIn('SIGNUP_GROUP', result)
@mock.patch("common.models.InvenTreeSetting.get_setting_definition")
def test_settings_validator(self, get_setting_definition):
"""Make sure that the validator function gets called on set setting."""
def validator(x):
if x == "hello":
return x
raise ValidationError(f"{x} is not valid")
mock_validator = mock.Mock(side_effect=validator)
# define partial schema
settings_definition = {
"AB": { # key that's has not already been accessed
"validator": mock_validator,
},
}
def mocked(key, **kwargs):
return settings_definition.get(key, {})
get_setting_definition.side_effect = mocked
InvenTreeSetting.set_setting("AB", "hello", self.user)
mock_validator.assert_called_with("hello")
with self.assertRaises(ValidationError):
InvenTreeSetting.set_setting("AB", "world", self.user)
mock_validator.assert_called_with("world")
def run_settings_check(self, key, setting):
"""Test that all settings are valid.

View File

@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q, Sum, UniqueConstraint
from django.db.models.signals import post_delete, post_save, pre_save
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -281,10 +281,13 @@ class Address(models.Model):
link: External link to additional address information
"""
class Meta:
"""Metaclass defines extra model options"""
verbose_name_plural = "Addresses"
def __init__(self, *args, **kwargs):
"""Custom init function"""
if 'confirm_primary' in kwargs:
self.confirm_primary = kwargs.pop('confirm_primary', None)
super().__init__(*args, **kwargs)
def __str__(self):
@ -304,25 +307,32 @@ class Address(models.Model):
return ", ".join(populated_lines)
class Meta:
"""Metaclass defines extra model options"""
verbose_name_plural = "Addresses"
def save(self, *args, **kwargs):
"""Run checks when saving an address:
- If this address is marked as "primary", ensure that all other addresses for this company are marked as non-primary
"""
others = list(Address.objects.filter(company=self.company).exclude(pk=self.pk).all())
# If this is the *only* address for this company, make it the primary one
if len(others) == 0:
self.primary = True
super().save(*args, **kwargs)
# Once this address is saved, check others
if self.primary:
for addr in others:
if addr.primary:
addr.primary = False
addr.save()
@staticmethod
def get_api_url():
"""Return the API URL associated with the Contcat model"""
return reverse('api-address-list')
def validate_unique(self, exclude=None):
"""Ensure that only one primary address exists per company"""
super().validate_unique(exclude=exclude)
if self.primary:
# Check that no other primary address exists for this company
if Address.objects.filter(company=self.company, primary=True).exclude(pk=self.pk).exists():
raise ValidationError({'primary': _('Company already has a primary address')})
company = models.ForeignKey(Company, related_name='addresses',
on_delete=models.CASCADE,
verbose_name=_('Company'),
@ -382,26 +392,6 @@ class Address(models.Model):
help_text=_('Link to address information (external)'))
@receiver(pre_save, sender=Address)
def check_primary(sender, instance, **kwargs):
"""Removes primary flag from current primary address if the to-be-saved address is marked as primary"""
if instance.company.primary_address is None:
instance.primary = True
# If confirm_primary is not present, this function does not need to do anything
if not hasattr(instance, 'confirm_primary') or \
instance.primary is False or \
instance.company.primary_address is None or \
instance.id == instance.company.primary_address.id:
return
if instance.confirm_primary is True:
adr = Address.objects.get(id=instance.company.primary_address.id)
adr.primary = False
adr.save()
class ManufacturerPart(MetadataMixin, models.Model):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.

View File

@ -67,11 +67,8 @@ class AddressSerializer(InvenTreeModelSerializer):
'shipping_notes',
'internal_shipping_notes',
'link',
'confirm_primary'
]
confirm_primary = serializers.BooleanField(default=False)
class AddressBriefSerializer(InvenTreeModelSerializer):
"""Serializer for Address Model (limited)"""

View File

@ -5,7 +5,7 @@
{% load inventree_extras %}
{% block page_title %}
{% inventree_title %} | {% trans "Supplier List" %}
{% inventree_title %}{% if title %} | {{ title }}{% endif %}
{% endblock page_title %}
{% block heading %}

View File

@ -27,7 +27,7 @@
{% block actions %}
{% if user.is_staff and perms.company.change_company %}
{% url 'admin:company_supplierpart_change' part.pk as url %}
{% url 'admin:company_manufacturerpart_change' part.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if roles.purchase_order.change %}

View File

@ -4,7 +4,6 @@ import os
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import transaction
from django.test import TestCase
from part.models import Part
@ -195,40 +194,27 @@ class AddressTest(TestCase):
def test_primary_constraint(self):
"""Test that there can only be one company-'primary=true' pair"""
c2 = Company.objects.create(name='Test Corp2.', description='We make stuff good')
Address.objects.create(company=self.c, primary=True)
Address.objects.create(company=self.c, primary=False)
self.assertEqual(Address.objects.count(), 2)
# Testing the constraint itself
# Intentionally throwing exceptions breaks unit tests unless performed in an atomic block
with transaction.atomic():
with self.assertRaises(ValidationError):
addr = Address(company=self.c, primary=True, confirm_primary=False)
addr.validate_unique()
self.assertTrue(Address.objects.first().primary)
Address.objects.create(company=c2, primary=True, line1="Hellothere", line2="generalkenobi")
with transaction.atomic():
with self.assertRaises(ValidationError):
addr = Address(company=c2, primary=True, confirm_primary=False)
addr.validate_unique()
# Create another address, specify *this* as primary
Address.objects.create(company=self.c, primary=True)
self.assertEqual(Address.objects.count(), 3)
self.assertFalse(Address.objects.first().primary)
self.assertTrue(Address.objects.last().primary)
def test_first_address_is_primary(self):
"""Test that first address related to company is always set to primary"""
addr = Address.objects.create(company=self.c)
self.assertTrue(addr.primary)
# Create another address, which should error out if primary is not set to False
with self.assertRaises(ValidationError):
addr = Address(company=self.c, primary=True)
addr.validate_unique()
def test_model_str(self):
"""Test value of __str__"""
t = "Test address"

View File

@ -182,13 +182,15 @@ class LabelConfig(AppConfig):
logger.info(f"Creating entry for {model} '{label['name']}'")
model.objects.create(
name=label['name'],
description=label['description'],
label=filename,
filters='',
enabled=True,
width=label['width'],
height=label['height'],
)
return
try:
model.objects.create(
name=label['name'],
description=label['description'],
label=filename,
filters='',
enabled=True,
width=label['width'],
height=label['height'],
)
except Exception:
logger.warning(f"Failed to create label '{label['name']}'")

View File

@ -171,6 +171,14 @@ class PurchaseOrderLineItemResource(PriceResourceMixin, InvenTreeResource):
SKU = Field(attribute='part__SKU', readonly=True)
def dehydrate_purchase_price(self, line):
"""Return a string value of the 'purchase_price' field, rather than the 'Money' object"""
if line.purchase_price:
return line.purchase_price.amount
else:
return ''
class PurchaseOrderExtraLineResource(PriceResourceMixin, InvenTreeResource):
"""Class for managing import / export of PurchaseOrderExtraLine data."""

View File

@ -674,8 +674,8 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
with transaction.atomic():
for item in items:
# Select location
loc = item.get('location', None) or item['line_item'].get_destination() or location
# Select location (in descending order of priority)
loc = location or item.get('location', None) or item['line_item'].get_destination()
try:
order.receive_line_item(
@ -879,7 +879,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
]
def __init__(self, *args, **kwargs):
"""Initializion routine for the serializer:
"""Initialization routine for the serializer:
- Add extra related serializer information if required
"""

View File

@ -982,9 +982,9 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(stock_1.count(), 1)
self.assertEqual(stock_2.count(), 1)
# Different location for each received item
# Same location for each received item, as overall 'location' field is provided
self.assertEqual(stock_1.last().location.pk, 1)
self.assertEqual(stock_2.last().location.pk, 2)
self.assertEqual(stock_2.last().location.pk, 1)
# Barcodes should have been assigned to the stock items
self.assertTrue(StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists())
@ -1562,7 +1562,7 @@ class SalesOrderDownloadTest(OrderTest):
self.assertTrue(isinstance(file, io.BytesIO))
def test_download_csv(self):
"""Tesst that the list of sales orders can be downloaded as a .csv file"""
"""Test that the list of sales orders can be downloaded as a .csv file"""
url = reverse('api-so-list')
required_cols = [
@ -1809,7 +1809,7 @@ class SalesOrderAllocateTest(OrderTest):
self.assertEqual(self.shipment.delivery_date, datetime(2023, 12, 5).date())
self.assertTrue(self.shipment.is_delivered())
def test_shipment_deliverydate(self):
def test_shipment_delivery_date(self):
"""Test delivery date functions via API."""
url = reverse('api-so-shipment-detail', kwargs={'pk': self.shipment.pk})
@ -1854,7 +1854,7 @@ class SalesOrderAllocateTest(OrderTest):
url = reverse('api-so-shipment-list')
# Count before creation
countbefore = models.SalesOrderShipment.objects.count()
count_before = models.SalesOrderShipment.objects.count()
# Create some new shipments via the API
for order in models.SalesOrder.objects.all():
@ -1885,7 +1885,7 @@ class SalesOrderAllocateTest(OrderTest):
# List *all* shipments
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), countbefore + 3 * models.SalesOrder.objects.count())
self.assertEqual(len(response.data), count_before + 3 * models.SalesOrder.objects.count())
class ReturnOrderTests(InvenTreeAPITestCase):

View File

@ -2031,10 +2031,6 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
if bom_item.part in my_ancestors and bom_item.inherited:
continue
# Skip if already exists
if BomItem.objects.filter(part=self, sub_part=bom_item.sub_part).exists():
continue
# Skip (or throw error) if BomItem is not valid
if not bom_item.sub_part.check_add_to_bom(self, raise_error=raise_error):
continue
@ -2350,6 +2346,8 @@ class PartPricing(common.models.MetaMixin):
- Detailed pricing information is very context specific in any case
"""
price_modified = False
@property
def is_valid(self):
"""Return True if the cached pricing is valid"""
@ -2476,7 +2474,7 @@ class PartPricing(common.models.MetaMixin):
pass
# Update parent assemblies and templates
if cascade:
if cascade and self.price_modified:
self.update_assemblies(counter)
self.update_templates(counter)
@ -2576,6 +2574,9 @@ class PartPricing(common.models.MetaMixin):
any_max_elements = True
old_bom_cost_min = self.bom_cost_min
old_bom_cost_max = self.bom_cost_max
if any_min_elements:
self.bom_cost_min = cumulative_min
else:
@ -2586,6 +2587,9 @@ class PartPricing(common.models.MetaMixin):
else:
self.bom_cost_max = None
if old_bom_cost_min != self.bom_cost_min or old_bom_cost_max != self.bom_cost_max:
self.price_modified = True
if save:
self.save()
@ -2650,6 +2654,9 @@ class PartPricing(common.models.MetaMixin):
if purchase_max is None or cost > purchase_max:
purchase_max = cost
if self.purchase_cost_min != purchase_min or self.purchase_cost_max != purchase_max:
self.price_modified = True
self.purchase_cost_min = purchase_min
self.purchase_cost_max = purchase_max
@ -2677,6 +2684,9 @@ class PartPricing(common.models.MetaMixin):
if max_int_cost is None or cost > max_int_cost:
max_int_cost = cost
if self.internal_cost_min != min_int_cost or self.internal_cost_max != max_int_cost:
self.price_modified = True
self.internal_cost_min = min_int_cost
self.internal_cost_max = max_int_cost
@ -2716,6 +2726,9 @@ class PartPricing(common.models.MetaMixin):
if max_sup_cost is None or cost > max_sup_cost:
max_sup_cost = cost
if self.supplier_price_min != min_sup_cost or self.supplier_price_max != max_sup_cost:
self.price_modified = True
self.supplier_price_min = min_sup_cost
self.supplier_price_max = max_sup_cost
@ -2753,6 +2766,9 @@ class PartPricing(common.models.MetaMixin):
if variant_max is None or v_max > variant_max:
variant_max = v_max
if self.variant_cost_min != variant_min or self.variant_cost_max != variant_max:
self.price_modified = True
self.variant_cost_min = variant_min
self.variant_cost_max = variant_max
@ -2877,6 +2893,9 @@ class PartPricing(common.models.MetaMixin):
if max_sell_history is None or cost > max_sell_history:
max_sell_history = cost
if self.sale_history_min != min_sell_history or self.sale_history_max != max_sell_history:
self.price_modified = True
self.sale_history_min = min_sell_history
self.sale_history_max = max_sell_history
@ -3532,15 +3551,6 @@ class PartParameter(MetadataMixin, models.Model):
super().clean()
# Validate the parameter data against the template units
if self.template.units:
try:
InvenTree.conversion.convert_physical_value(self.data, self.template.units)
except ValidationError as e:
raise ValidationError({
'data': e.message
})
# Validate the parameter data against the template choices
if choices := self.template.get_choices():
if self.data not in choices:
@ -3749,6 +3759,18 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
"""Return the list API endpoint URL associated with the BomItem model"""
return reverse('api-bom-list')
def get_assemblies(self):
"""Return a list of assemblies which use this BomItem"""
assemblies = [self.part]
if self.inherited:
assemblies += list(
self.part.get_descendants(include_self=False)
)
return assemblies
def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True):
"""Return a list of valid parts which can be allocated against this BomItem.
@ -4048,6 +4070,18 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
@receiver(post_save, sender=BomItem, dispatch_uid='update_bom_build_lines')
def update_bom_build_lines(sender, instance, created, **kwargs):
"""Update existing build orders when a BomItem is created or edited"""
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
import build.tasks
InvenTree.tasks.offload_task(
build.tasks.update_build_order_lines,
instance.pk
)
@receiver(post_save, sender=BomItem, dispatch_uid='post_save_bom_item')
@receiver(post_save, sender=PartSellPriceBreak, dispatch_uid='post_save_sale_price_break')
@receiver(post_save, sender=PartInternalPriceBreak, dispatch_uid='post_save_internal_price_break')

View File

@ -314,10 +314,6 @@ def generate_stocktake_report(**kwargs):
# Create a new stocktake for this part (do not commit, this will take place later on)
stocktake = perform_stocktake(p, user, commit=False)
if stocktake.quantity == 0:
# Skip rows with zero total quantity
continue
total_parts += 1
stocktake_instances.append(stocktake)

View File

@ -38,9 +38,13 @@ def sso_check_provider(provider):
from allauth.socialaccount.models import SocialApp
# First, check that the provider is enabled
apps = SocialApp.objects.filter(provider__iexact=provider.name)
apps = SocialApp.objects.filter(provider__iexact=provider.id)
if not apps.exists():
logging.error(
"SSO SocialApp %s does not exist (known providers: %s)",
provider.id, [obj.provider for obj in SocialApp.objects.all()]
)
return False
# Next, check that the provider is correctly configured

View File

@ -2966,7 +2966,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):
data = response.data[0]
self.assertEqual(data['part_count'], 8)
self.assertEqual(data['part_count'], 14)
self.assertEqual(data['user'], None)
self.assertTrue(data['report'].endswith('.csv'))

View File

@ -168,12 +168,6 @@ class ParameterTests(TestCase):
param = PartParameter(part=prt, template=template, data=value)
param.full_clean()
# Test that invalid parameters fail
for value in ['3 Amps', '-3 zogs', '3.14F']:
param = PartParameter(part=prt, template=template, data=value)
with self.assertRaises(django_exceptions.ValidationError):
param.full_clean()
def test_param_unit_conversion(self):
"""Test that parameters are correctly converted to template units"""

View File

@ -168,7 +168,11 @@ class PartImport(FileManagementFormView):
for row in self.rows:
# check each submitted column
for idx in col_ids:
data = row['data'][col_ids[idx]]['cell']
try:
data = row['data'][col_ids[idx]]['cell']
except (IndexError, TypeError):
continue
if idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
try:

View File

@ -103,6 +103,12 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
# Save plugin
self.plugin: InvenTreePlugin = plugin
def __getstate__(self):
"""Customize pickeling behaviour."""
state = super().__getstate__()
state.pop("plugin", None) # plugin cannot be pickelt in some circumstances when used with drf views, remove it (#5408)
return state
def save(self, force_insert=False, force_update=False, *args, **kwargs):
"""Extend save method to reload plugins if the 'active' status changes."""
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set

View File

@ -1,5 +1,8 @@
"""Sample implementations for IntegrationPlugin."""
import json
from django.core.exceptions import ValidationError
from django.http import HttpResponse
from django.urls import include, re_path
from django.utils.translation import gettext_lazy as _
@ -8,6 +11,14 @@ from plugin import InvenTreePlugin
from plugin.mixins import AppMixin, NavigationMixin, SettingsMixin, UrlsMixin
def validate_json(value):
"""Example validator for json input."""
try:
json.loads(value)
except Exception as e:
raise ValidationError(str(e))
class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, InvenTreePlugin):
"""A full plugin example."""
@ -72,6 +83,17 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
'description': 'Select a part object from the database',
'model': 'part.part',
},
'PROTECTED_SETTING': {
'name': 'Protected Setting',
'description': 'A protected setting, hidden from the UI',
'default': 'ABC-123',
'protected': True,
},
'VALIDATOR_SETTING': {
'name': 'JSON validator Setting',
'description': 'A setting using a JSON validator',
'validator': validate_json,
}
}
NAVIGATION = [

View File

@ -1,6 +1,9 @@
"""Unit tests for action plugins."""
from django.core.exceptions import ValidationError
from InvenTree.unit_test import InvenTreeTestCase
from plugin import registry
class SampleIntegrationPluginTests(InvenTreeTestCase):
@ -11,3 +14,17 @@ class SampleIntegrationPluginTests(InvenTreeTestCase):
response = self.client.get('/plugin/sample/ho/he/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'Hi there testuser this works')
def test_settings_validator(self):
"""Test settings validator for plugins."""
plugin = registry.get_plugin('sample')
valid_json = '{"ts": 13}'
not_valid_json = '{"ts""13"}'
# no error, should pass validator
plugin.set_setting('VALIDATOR_SETTING', valid_json)
# should throw an error
with self.assertRaises(ValidationError):
plugin.set_setting('VALIDATOR_SETTING', not_valid_json)

View File

@ -153,13 +153,29 @@ class PluginConfigInstallSerializer(serializers.Serializer):
success = False
# execute pypi
try:
result = subprocess.check_output(command, cwd=settings.BASE_DIR.parent)
result = subprocess.check_output(command, cwd=settings.BASE_DIR.parent, stderr=subprocess.STDOUT)
ret['result'] = str(result, 'utf-8')
ret['success'] = True
ret['error'] = False
success = True
except subprocess.CalledProcessError as error: # pragma: no cover
ret['result'] = str(error.output, 'utf-8')
ret['error'] = True
output = error.output.decode('utf-8')
# Raise a ValidationError as the plugin install failed
errors = []
for msg in output.split('\n'):
msg = msg.strip()
if msg:
errors.append(msg)
if len(errors) == 0:
errors.append(_('Unknown error'))
if len(errors) > 1:
raise ValidationError(errors)
else:
raise ValidationError(errors[0])
# save plugin to plugin_file if installed successful
if success:

View File

@ -193,3 +193,76 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
with self.assertRaises(NotFound) as exc:
check_plugin(plugin_slug=None, plugin_pk='123')
self.assertEqual(str(exc.exception.detail), "Plugin '123' not installed")
def test_plugin_settings(self):
"""Test plugin settings access via the API"""
# Ensure we have superuser permissions
self.user.is_superuser = True
self.user.save()
# Activate the 'sample' plugin via the API
cfg = PluginConfig.objects.filter(key='sample').first()
url = reverse('api-plugin-detail-activate', kwargs={'pk': cfg.pk})
self.client.patch(url, {}, expected_code=200)
# Valid plugin settings endpoints
valid_settings = [
'SELECT_PART',
'API_KEY',
'NUMERICAL_SETTING',
]
for key in valid_settings:
response = self.get(
reverse('api-plugin-setting-detail', kwargs={
'plugin': 'sample',
'key': key
}))
self.assertEqual(response.data['key'], key)
# Test that an invalid setting key raises a 404 error
response = self.get(
reverse('api-plugin-setting-detail', kwargs={
'plugin': 'sample',
'key': 'INVALID_SETTING'
}),
expected_code=404
)
# Test that a protected setting returns hidden value
response = self.get(
reverse('api-plugin-setting-detail', kwargs={
'plugin': 'sample',
'key': 'PROTECTED_SETTING'
}),
expected_code=200
)
self.assertEqual(response.data['value'], '***')
# Test that we can update a setting value
response = self.patch(
reverse('api-plugin-setting-detail', kwargs={
'plugin': 'sample',
'key': 'NUMERICAL_SETTING'
}),
{
'value': 456
},
expected_code=200
)
self.assertEqual(response.data['value'], '456')
# Retrieve the value again
response = self.get(
reverse('api-plugin-setting-detail', kwargs={
'plugin': 'sample',
'key': 'NUMERICAL_SETTING'
}),
expected_code=200
)
self.assertEqual(response.data['value'], '456')

View File

@ -18,6 +18,7 @@ import InvenTree.helpers
import order.models
import part.models
from InvenTree.api import MetadataView
from InvenTree.exceptions import log_error
from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
from stock.models import StockItem, StockItemAttachment
@ -181,78 +182,90 @@ class ReportPrintMixin:
# Start with a default report name
report_name = "report.pdf"
# Merge one or more PDF files into a single download
for item in items_to_print:
report = self.get_object()
report.object_to_print = item
try:
# Merge one or more PDF files into a single download
for item in items_to_print:
report = self.get_object()
report.object_to_print = item
report_name = report.generate_filename(request)
output = report.render(request)
report_name = report.generate_filename(request)
output = report.render(request)
# Run report callback for each generated report
self.report_callback(item, output, request)
# Run report callback for each generated report
self.report_callback(item, output, request)
try:
if debug_mode:
outputs.append(report.render_as_string(request))
else:
outputs.append(output)
except TemplateDoesNotExist as e:
template = str(e)
if not template:
template = report.template
try:
if debug_mode:
outputs.append(report.render_as_string(request))
else:
outputs.append(output)
except TemplateDoesNotExist as e:
template = str(e)
if not template:
template = report.template
return Response(
{
'error': _(f"Template file '{template}' is missing or does not exist"),
},
status=400,
return Response(
{
'error': _(f"Template file '{template}' is missing or does not exist"),
},
status=400,
)
if not report_name.endswith('.pdf'):
report_name += '.pdf'
if debug_mode:
"""Contatenate all rendered templates into a single HTML string, and return the string as a HTML response."""
html = "\n".join(outputs)
return HttpResponse(html)
else:
"""Concatenate all rendered pages into a single PDF object, and return the resulting document!"""
pages = []
try:
for output in outputs:
doc = output.get_document()
for page in doc.pages:
pages.append(page)
pdf = outputs[0].get_document().copy(pages).write_pdf()
except TemplateDoesNotExist as e:
template = str(e)
if not template:
template = report.template
return Response(
{
'error': _(f"Template file '{template}' is missing or does not exist"),
},
status=400,
)
inline = common.models.InvenTreeUserSetting.get_setting('REPORT_INLINE', user=request.user, cache=False)
return InvenTree.helpers.DownloadFile(
pdf,
report_name,
content_type='application/pdf',
inline=inline,
)
if not report_name.endswith('.pdf'):
report_name += '.pdf'
except Exception as exc:
# Log the exception to the database
log_error(request.path)
if debug_mode:
"""Contatenate all rendered templates into a single HTML string, and return the string as a HTML response."""
html = "\n".join(outputs)
return HttpResponse(html)
else:
"""Concatenate all rendered pages into a single PDF object, and return the resulting document!"""
pages = []
try:
for output in outputs:
doc = output.get_document()
for page in doc.pages:
pages.append(page)
pdf = outputs[0].get_document().copy(pages).write_pdf()
except TemplateDoesNotExist as e:
template = str(e)
if not template:
template = report.template
return Response(
{
'error': _(f"Template file '{template}' is missing or does not exist"),
},
status=400,
)
inline = common.models.InvenTreeUserSetting.get_setting('REPORT_INLINE', user=request.user, cache=False)
return InvenTree.helpers.DownloadFile(
pdf,
report_name,
content_type='application/pdf',
inline=inline,
)
# Re-throw the exception to the client as a DRF exception
raise ValidationError({
'error': 'Report printing failed',
'detail': str(exc),
'path': request.path,
})
def get(self, request, *args, **kwargs):
"""Default implementation of GET for a print endpoint.

View File

@ -677,7 +677,11 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
if bool(data.get('use_pack_size')):
quantity = data['quantity'] = supplier_part.base_quantity(quantity)
# Divide purchase price by pack size, to save correct price per stock item
data['purchase_price'] = float(data['purchase_price']) / float(supplier_part.pack_quantity_native)
try:
data['purchase_price'] = float(data['purchase_price']) / float(supplier_part.pack_quantity_native)
except ValueError:
# If the purchase price is not a number, ignore it
pass
# Now remove the flag from data, so that it doesn't interfere with saving
# Do this regardless of results above

View File

@ -69,6 +69,7 @@ def fix_purchase_price(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('company', '0047_supplierpart_pack_size'),
('stock', '0093_auto_20230217_2140'),
]

View File

@ -368,7 +368,12 @@
<tr>
<td><span class='fas fa-th-list'></span></td>
<td>{% trans "Sales Order" %}</td>
<td><a href="{% url 'so-detail' item.sales_order.id %}">{{ item.sales_order.reference }}</a> - <a href="{% url 'company-detail' item.sales_order.customer.id %}">{{ item.sales_order.customer.name }}</a></td>
<td>
<a href="{% url 'so-detail' item.sales_order.id %}">{{ item.sales_order.reference }}</a>
{% if item.sales_order.customer %}
- <a href="{% url 'company-detail' item.sales_order.customer.id %}">{{ item.sales_order.customer.name }}</a>
{% endif %}
</td>
</tr>
{% else %}
{% if allocated_to_sales_orders %}

View File

@ -22,7 +22,9 @@
{{ setting.description }}
</td>
<td>
{% if setting.is_bool %}
{% if setting.protected %}
<span style='color: red;'>***</span> <span class='fas fa-lock icon-red'></span>
{% elif setting.is_bool %}
{% include "InvenTree/settings/setting_boolean.html" %}
{% else %}
<div id='setting-{{ setting.pk }}'>

View File

@ -281,10 +281,20 @@ function loadAttachmentTable(url, options) {
sidePagination: 'server',
onPostBody: function() {
// Add callback for 'delete' button
if (permissions.delete) {
$(table).find('.button-attachment-delete').click(function() {
let pk = $(this).attr('pk');
let attachments = $(table).bootstrapTable('getRowByUniqueId', pk);
deleteAttachments([attachments], url, options);
});
}
// Add callback for 'edit' button
if (permissions.change) {
$(table).find('.button-attachment-edit').click(function() {
var pk = $(this).attr('pk');
let pk = $(this).attr('pk');
constructForm(`${url}${pk}/`, {
fields: {

View File

@ -905,6 +905,18 @@ function loadBomTable(table, options={}) {
title: '{% trans "Part" %}',
sortable: true,
switchable: false,
sorter: function(_valA, _valB, rowA, rowB) {
let name_a = rowA.sub_part_detail.full_name;
let name_b = rowB.sub_part_detail.full_name;
if (name_a > name_b) {
return 1;
} else if (name_a < name_b) {
return -1;
} else {
return 0;
}
},
formatter: function(value, row) {
var url = `/part/${row.sub_part}/`;
var html = '';

View File

@ -2173,9 +2173,6 @@ function loadBuildTable(table, options) {
customView: function(data) {
return `<div id='build-order-calendar'></div>`;
},
onRefresh: function() {
loadBuildTable(table, options);
},
onLoadSuccess: function() {
if (tree_enable) {
@ -2255,16 +2252,16 @@ function renderBuildLineAllocationTable(element, build_line, options={}) {
{
field: 'part',
title: '{% trans "Part" %}',
formatter: function(value, row) {
formatter: function(_value, row) {
let html = imageHoverIcon(row.part_detail.thumbnail);
html += renderLink(row.part_detail.full_name, `/part/${value}/`);
html += renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`);
return html;
}
},
{
field: 'quantity',
title: '{% trans "Allocated Quantity" %}',
formatter: function(value, row) {
formatter: function(_value, row) {
let text = '';
let url = '';
let serial = row.serial;
@ -2294,8 +2291,8 @@ function renderBuildLineAllocationTable(element, build_line, options={}) {
title: '{% trans "Location" %}',
formatter: function(value, row) {
if (row.location_detail) {
var text = shortenString(row.location_detail.pathstring);
var url = `/stock/location/${row.location}/`;
let text = shortenString(row.location_detail.pathstring);
let url = `/stock/location/${row.location_detail.pk}/`;
return renderLink(text, url);
} else {
@ -2660,6 +2657,7 @@ function loadBuildLineTable(table, build_id, options={}) {
deallocateStock(build_id, {
build_line: pk,
output: output,
onSuccess: function() {
$(table).bootstrapTable('refresh');
}

View File

@ -27,6 +27,7 @@
showFormInput,
thumbnailImage,
wrapButtons,
yesNoLabel,
*/
/* exported
@ -798,45 +799,7 @@ function addressFields(options={}) {
company: {
icon: 'fa-building',
},
primary: {
onEdit: function(val, name, field, opts) {
if (val === false) {
hideFormInput("confirm_primary", opts);
$('#id_confirm_primary').prop("checked", false);
clearFormErrors(opts);
enableSubmitButton(opts, true);
} else if (val === true) {
showFormInput("confirm_primary", opts);
if($('#id_confirm_primary').prop("checked") === false) {
handleFormErrors({'confirm_primary': 'WARNING: Setting this address as primary will remove primary flag from other addresses'}, field, {});
enableSubmitButton(opts, false);
}
}
}
},
confirm_primary: {
help_text: "Confirm",
onEdit: function(val, name, field, opts) {
if (val === true) {
clearFormErrors(opts);
enableSubmitButton(opts, true);
} else if (val === false) {
handleFormErrors({'confirm_primary': 'WARNING: Setting this address as primary will remove primary flag from other addresses'}, field, {});
enableSubmitButton(opts, false);
}
},
css: {
display: 'none'
}
},
primary: {},
title: {},
line1: {
icon: 'fa-map'
@ -984,11 +947,7 @@ function loadAddressTable(table, options={}) {
title: '{% trans "Primary" %}',
switchable: false,
formatter: function(value) {
let checked = '';
if (value == true) {
checked = 'checked="checked"';
}
return `<input type="checkbox" ${checked} disabled="disabled" value="${value? 1 : 0}">`;
return yesNoLabel(value);
}
},
{

View File

@ -1404,6 +1404,7 @@ function createPartParameter(part_id, options={}) {
function editPartParameter(param_id, options={}) {
options.fields = partParameterFields();
options.title = '{% trans "Edit Parameter" %}';
options.focus = 'data';
options.processBeforeUpload = function(data) {
// Convert data to string
@ -2367,6 +2368,38 @@ function loadPartTable(table, url, options={}) {
});
return text;
},
footerFormatter: function(data) {
// Display "total" stock quantity of all rendered rows
// Requires that all parts have the same base units!
let total = 0;
let units = new Set();
data.forEach(function(row) {
units.add(row.units || null);
if (row.total_in_stock != null) {
total += row.total_in_stock;
}
});
if (data.length == 0) {
return '-';
} else if (units.size > 1) {
return '-';
} else {
let output = `${total}`;
if (units.size == 1) {
let unit = units.values().next().value;
if (unit) {
output += ` [${unit}]`;
}
}
return output;
}
}
});
@ -2442,6 +2475,7 @@ function loadPartTable(table, url, options={}) {
showColumns: true,
showCustomView: grid_view,
showCustomViewButton: false,
showFooter: true,
onPostBody: function() {
grid_view = inventreeLoad('part-grid-view') == 1;
if (grid_view) {

View File

@ -292,7 +292,7 @@ function loadBomPricingChart(options={}) {
var part = options.part;
if (!part) {
console.error('No part provided to loadPurchasePriceHistoryTable');
console.error('No part provided to loadBomPricingChart');
return;
}
@ -434,7 +434,7 @@ function loadPartSupplierPricingTable(options={}) {
var part = options.part;
if (!part) {
console.error('No part provided to loadPurchasePriceHistoryTable');
console.error('No part provided to loadPartSupplierPricingTable');
return;
}
@ -764,7 +764,21 @@ function loadPurchasePriceHistoryTable(options={}) {
data = data.sort((a, b) => (a.order_detail.complete_date - b.order_detail.complete_date));
var graphLabels = Array.from(data, (x) => (`${x.order_detail.reference} - ${x.order_detail.complete_date}`));
var graphValues = Array.from(data, (x) => (x.purchase_price / x.supplier_part_detail.pack_size));
var graphValues = Array.from(data, (x) => {
let pp = x.purchase_price;
let div = 1.0;
if (x.supplier_part_detail) {
div = parseFloat(x.supplier_part_detail.pack_quantity_native);
if (isNaN(div) || !isFinite(div)) {
div = 1.0;
}
}
return pp / div;
});
if (chart) {
chart.destroy();

View File

@ -1759,9 +1759,6 @@ function loadPurchaseOrderTable(table, options) {
customView: function(data) {
return `<div id='purchase-order-calendar'></div>`;
},
onRefresh: function() {
loadPurchaseOrderTable(table, options);
},
onLoadSuccess: function() {
if (display_mode == 'calendar') {

View File

@ -262,9 +262,6 @@ function loadReturnOrderTable(table, options={}) {
formatNoMatches: function() {
return '{% trans "No return orders found" %}';
},
onRefresh: function() {
loadReturnOrderTable(table, options);
},
onLoadSuccess: function() {
// TODO
},

View File

@ -735,9 +735,6 @@ function loadSalesOrderTable(table, options) {
customView: function(data) {
return `<div id='purchase-order-calendar'></div>`;
},
onRefresh: function() {
loadSalesOrderTable(table, options);
},
onLoadSuccess: function() {
if (display_mode == 'calendar') {

View File

@ -2068,13 +2068,36 @@ function loadStockTable(table, options) {
// Display "total" stock quantity of all rendered rows
let total = 0;
// Keep track of the whether all units are the same
// If different units are found, we cannot aggregate the quantities
let units = new Set();
data.forEach(function(row) {
units.add(row.part_detail.units || null);
if (row.quantity != null) {
total += row.quantity;
}
});
return total;
if (data.length == 0) {
return '-';
} else if (units.size > 1) {
return '-';
} else {
let output = `${total}`;
if (units.size == 1) {
let unit = units.values().next().value;
if (unit) {
output += ` [${unit}]`;
}
}
return output;
}
}
};
@ -2352,6 +2375,10 @@ function loadStockTable(table, options) {
let row = table.bootstrapTable('getRowByUniqueId', stock_item);
row.installed_items_received = true;
for (let ii = 0; ii < response.length; ii++) {
response[ii].belongs_to_item = stock_item;
}
table.bootstrapTable('updateByUniqueId', stock_item, row, true);
table.bootstrapTable('append', response);
@ -2367,6 +2394,7 @@ function loadStockTable(table, options) {
}
let parent_id = 'top-level';
let loaded = false;
table.inventreeTable({
method: 'get',
@ -2382,13 +2410,25 @@ function loadStockTable(table, options) {
showFooter: true,
columns: columns,
treeEnable: show_installed_items,
rootParentId: parent_id,
parentIdField: 'belongs_to',
rootParentId: show_installed_items ? parent_id : null,
parentIdField: show_installed_items ? 'belongs_to_item' : null,
uniqueId: 'pk',
idField: 'pk',
treeShowField: 'part',
onPostBody: function() {
treeShowField: show_installed_items ? 'part' : null,
onLoadSuccess: function(data) {
let records = data.results || data;
// Set the 'parent' ID for each root item
if (!loaded && show_installed_items) {
for (let i = 0; i < records.length; i++) {
records[i].belongs_to_item = parent_id;
}
loaded = true;
$(table).bootstrapTable('load', records);
}
},
onPostBody: function() {
if (show_installed_items) {
table.treegrid({
treeColumn: 1,
@ -2618,7 +2658,7 @@ function loadStockLocationTable(table, options) {
} else {
html += `
<a href='#' pk='${row.pk}' class='load-sub-location'>
<span class='fas fa-sync-alt' title='{% trans "Load Subloactions" %}'></span>
<span class='fas fa-sync-alt' title='{% trans "Load Sublocations" %}'></span>
</a> `;
}
}
@ -2809,7 +2849,7 @@ function loadStockTrackingTable(table, options) {
if (details.salesorder_detail) {
html += renderLink(
details.salesorder_detail.reference,
`/order/sales-order/${details.salesorder}`
`/order/sales-order/${details.salesorder}/`
);
} else {
html += `<em>{% trans "Sales Order no longer exists" %}</em>`;

View File

@ -373,7 +373,7 @@ def update_group_roles(group, debug=False):
# Iterate through each permission already assigned to this group,
# and create a simplified permission key string
for p in group.permissions.all():
for p in group.permissions.all().prefetch_related('content_type'):
(permission, app, model) = p.natural_key()
permission_string = '{app}.{perm}'.format(

View File

@ -1,3 +1,3 @@
web: env/bin/gunicorn --chdir $APP_HOME/InvenTree -c InvenTree/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:$PORT
worker: env/bin/python InvenTree/manage.py qcluster
cli: . env/bin/activate && exec env/bin/python -m invoke
cli: echo "" && . env/bin/activate && exec env/bin/python -m invoke

View File

@ -42,7 +42,7 @@ INVENTREE_DB_PORT=5432
#INVENTREE_CACHE_PORT=6379
# Options for gunicorn server
INVENTREE_GUNICORN_TIMEOUT=30
INVENTREE_GUNICORN_TIMEOUT=90
# Enable custom plugins?
INVENTREE_PLUGINS_ENABLED=False

View File

@ -2,7 +2,7 @@
# Basic package requirements
invoke>=1.4.0 # Invoke build tool
pyyaml>=6.0
pyyaml>=6.0.1
setuptools==65.6.3
wheel>=0.37.0

View File

@ -17,7 +17,7 @@ In addition to providing the ability for end-users to provide their own reportin
InvenTree report templates utilize the powerful [WeasyPrint](https://weasyprint.org/) PDF generation engine.
!!! info "WeasyPrint"
WeasyPrint is an extremely powerful and flexible reporting library. Refer to the [WeasyPrint docs](https://weasyprint.readthedocs.io/en/stable/) for further information.
WeasyPrint is an extremely powerful and flexible reporting library. Refer to the [WeasyPrint docs](https://doc.courtbouillon.org/weasyprint/stable/) for further information.
### Stylesheets

View File

@ -4,10 +4,10 @@ title: InvenTree Single Sign On
## Single Sign On
InvenTree provides the possibility to use 3rd party services to authenticate users. This functionality makes use of [django-allauth](https://django-allauth.readthedocs.io/en/latest/) and supports a wide array of OpenID and OAuth [providers](https://django-allauth.readthedocs.io/en/latest/providers.html).
InvenTree provides the possibility to use 3rd party services to authenticate users. This functionality makes use of [django-allauth](https://django-allauth.readthedocs.io/en/latest/) and supports a wide array of OpenID and OAuth [providers](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html).
!!! tip "Provider Documentation"
There are a lot of technical considerations when configuring a particular SSO provider. A good starting point is the [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/providers.html)
There are a lot of technical considerations when configuring a particular SSO provider. A good starting point is the [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html)
## SSO Configuration
@ -28,8 +28,8 @@ There are two variables in the configuration file which define the operation of
| Key | Description | More Info |
| --- | --- | --- |
| `social_backends` | A *list* of provider backends enabled for the InvenTree instance | [django-allauth docs](https://django-allauth.readthedocs.io/en/latest/installation.html) |
| `social_providers` | A *dict* of settings specific to the installed providers | [provider documentation](https://django-allauth.readthedocs.io/en/latest/providers.html) |
| `social_backends` | A *list* of provider backends enabled for the InvenTree instance | [django-allauth docs](https://django-allauth.readthedocs.io/en/latest/installation/quickstart.html) |
| `social_providers` | A *dict* of settings specific to the installed providers | [provider documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) |
In the example below, SSO provider modules are activated for *google*, *github* and *microsoft*. Specific configuration options are specified for the *microsoft* provider module:
@ -48,7 +48,7 @@ In the example below, SSO provider modules are activated for *google*, *github*
The next step is to create an external authentication app with your provider of choice. This step is wholly separate to your InvenTree installation, and must be performed before continuing further.
!!! info "Read the Documentation"
The [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/providers.html) is a good starting point here. There are also a number of good tutorials online (at least for the major supported SSO providers).
The [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) is a good starting point here. There are also a number of good tutorials online (at least for the major supported SSO providers).
In general, the external app will generate a *key* and *secret* pair - although different terminology may be used, depending on the provider.

View File

@ -155,9 +155,16 @@ The following email settings are available:
| INVENTREE_EMAIL_PASSWORD | email.password | Email account password | *Not specified* |
| INVENTREE_EMAIL_TLS | email.tls | Enable TLS support | False |
| INVENTREE_EMAIL_SSL | email.ssl | Enable SSL support | False |
| INVENTREE_EMAIL_SENDER | email.sender | Name of sender | *Not specified* |
| INVENTREE_EMAIL_SENDER | email.sender | Sending email address | *Not specified* |
| INVENTREE_EMAIL_PREFIX | email.prefix | Prefix for subject text | [InvenTree] |
### Sender Email
The "sender" email address is the address from which InvenTree emails are sent (by default) and must be specified for outgoing emails to function:
!!! info "Fallback"
If `INVENTREE_EMAIL_SENDER` is not provided, the system will fall back to `INVENTREE_EMAIL_USERNAME` (if the username is a valid email address)
## Supported Currencies
The currencies supported by InvenTree must be specified in the [configuration file](#configuration-file).
@ -228,9 +235,9 @@ InvenTree provides allowance for additional sign-in options. The following optio
### Single Sign On
SSO backends for all required authentication providers need to be added to the config file as a list under the key `social_backends`. The correct backend-name can be found in django-allauths [configuration documentation](https://django-allauth.readthedocs.io/en/latest/installation.html#django).
SSO backends for all required authentication providers need to be added to the config file as a list under the key `social_backends`. The correct backend-name can be found in django-allauths [configuration documentation](https://django-allauth.readthedocs.io/en/latest/installation/quickstart.html).
If the selected providers need additional settings they must be added as dicts under the key `social_providers`. The correct settings can be found in the django-allauths [provider documentation](https://django-allauth.readthedocs.io/en/latest/providers.html).
If the selected providers need additional settings they must be added as dicts under the key `social_providers`. The correct settings can be found in the django-allauths [provider documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html).
!!! warning "You are not done"
SSO still needs credentials for all providers and has to be enabled in the [global settings](../settings/global.md)!

View File

@ -35,7 +35,7 @@ sudo apt-get install \
```
!!! warning "Weasyprint"
On some systems, the dependencies for the `weasyprint` package might not be installed. Consider running through the [weasyprint installation steps](https://weasyprint.readthedocs.io/en/stable/install.html) before moving forward.
On some systems, the dependencies for the `weasyprint` package might not be installed. Consider running through the [weasyprint installation steps](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#installation) before moving forward.
### Create InvenTree User

View File

@ -4,6 +4,10 @@ mkdocs:
configuration: docs/mkdocs.yml
python:
version: 3.7
install:
- requirements: docs/requirements.txt
build:
os: "ubuntu-22.04"
tools:
python: "3.9"

View File

@ -86,7 +86,7 @@ pytz==2023.3
# via
# -c requirements.txt
# django
pyyaml==6.0
pyyaml==6.0.1
# via
# -c requirements.txt
# pre-commit

View File

@ -40,6 +40,7 @@ pillow # Image manipulation
pint==0.21 # Unit conversion # FIXED 2023-05-30 breaks tests https://github.com/matmair/InvenTree/actions/runs/5095665936/jobs/9160852560
python-barcode[images] # Barcode generator
python-dotenv # Environment variable management
pyyaml>=6.0.1 # YAML parsing
qrcode[pil] # QR code generator
rapidfuzz==0.7.6 # Fuzzy string matching
regex # Advanced regular expressions

View File

@ -239,8 +239,9 @@ pytz==2023.3
# django-dbbackup
# djangorestframework
# icalendar
pyyaml==6.0
pyyaml==6.0.1
# via
# -r requirements.in
# drf-spectacular
# tablib
qrcode[pil]==7.4.2