Compare commits

...

24 Commits

Author SHA1 Message Date
Oliver
216e09664b Increment version number to 0.7.7 (#3403) 2022-07-26 12:01:52 +10:00
Oliver
a0813dd3c1 Make sure PIP is upgraded to latest version (#3402)
Co-authored-by: Matthias Mair <code@mjmair.com>
2022-07-26 12:01:43 +10:00
Oliver
cb540ebe90 [FR] Automated releases (#3316) (#3380)
* [FR] Automated releases
automated messages on the socials
Fixes #3078

* Add more details to Reddit

* Fix twitter text

* fix syntax

* Update release.yml

Add hashtags to twitter post

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
(cherry picked from commit 6133c745d7)

Co-authored-by: Matthias Mair <code@mjmair.com>
2022-07-21 16:10:53 +10:00
Oliver
2ab1d989ae Bump version number for stable branch (#3379) 2022-07-21 16:10:23 +10:00
Oliver
57cb769317 Handle exception when path is not relative to base path (#3378)
(cherry picked from commit 2bdba081b5)
2022-07-21 15:22:54 +10:00
Oliver
efc360f22f Fix API filtering for PurchaseOrderLineItem (#3356) (#3376)
(cherry picked from commit f0d69ec458)
2022-07-21 13:57:56 +10:00
nwns
ffe66472fe Allow supplier parts to be search by part.keywords field (#3278) (#3375)
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2022-07-21 11:20:29 +10:00
Oliver
c17b34a864 Specify upper bound for mariadb python version (#3333)
- 1.1.0 and above causes build process to break
2022-07-15 14:30:34 +10:00
Oliver
5d44811f98 Bump version number to 0.7.5 (#3324)
* Bump version number to 0.7.5

* Add fix for stock migration

- Ensure the serial number is not too large when performing migration
- Add unit test for data migration

(cherry picked from commit 661fbf0e3d)
(cherry picked from commit 233446c2bb)

* Add similar fixes for PO and SO migrations

(cherry picked from commit bde23c130c)
(cherry picked from commit 4261090e6d)

* And similar fix for BuildOrder reference field

(cherry picked from commit ca0f4e0031)
(cherry picked from commit 9fa4ee48d6)

* Fix for plugin unit testing

* Revert test database name

(cherry picked from commit 53333c29c3)

* Override default URL behaviour for unit test

(cherry picked from commit 2c12a69529)
2022-07-15 11:58:06 +10:00
Oliver
49c61f74b1 Migration bug fix (#3325)
* Add fix for stock migration

- Ensure the serial number is not too large when performing migration
- Add unit test for data migration

(cherry picked from commit 661fbf0e3d)
(cherry picked from commit 233446c2bb)

* Add similar fixes for PO and SO migrations

(cherry picked from commit bde23c130c)
(cherry picked from commit 4261090e6d)

* And similar fix for BuildOrder reference field

(cherry picked from commit ca0f4e0031)
(cherry picked from commit 9fa4ee48d6)

* Fix for plugin unit testing

* Revert test database name

(cherry picked from commit 53333c29c3)

* Override default URL behaviour for unit test

(cherry picked from commit 2c12a69529)
2022-07-15 11:56:02 +10:00
Oliver
a19d342800 Fix translation issue with javascript (#3246) (#3252)
* Adds a custom translation node class to strip dirty characters from translated strings

* Update javascript files to use new template tag

* Override behaviour of {% load i18n %}

- No longer requires custom tag loading
- All templates now use escaped translation values
- Requires re-ordering of app loading
- Revert js_i18n to simply i18n

* CI step now lints JS files compiled in each locale

* Checking that the CI step fails

* Revert "Checking that the CI step fails"

This reverts commit ba2be0470d.

(cherry picked from commit 44b42050aa)
2022-06-25 11:28:17 +10:00
Oliver
32b81aa598 Bump version number to 0.7.4 (#3241) 2022-06-23 14:23:17 +10:00
Oliver
5196fd5546 Prevent newline chars from breaking part detail page (#3245) 2022-06-23 14:23:07 +10:00
Oliver
f9aa5a60fd Override 2FA token removal form (#3240)
- Requires user to input valid token to remove 2FA for their account

Co-authored-by: Matthias Mair <code@mjmair.com>
2022-06-23 13:04:53 +10:00
Oliver
b9c6cd70d4 Enable "sanitize" option for EasyMDE editor (#3206)
* Enable "sanitize" option for EasyMDE editor

* Bump version number
2022-06-16 13:31:20 +10:00
Oliver
26bf51c20a Back porting of security patches (#3197)
* Merge pull request from GHSA-fr2w-mp56-g4xp

* Enforce file download for attachments table(s)

* Enforce file download for attachment in 'StockItemTestResult' table

(cherry picked from commit 76aa3a75f2)

* Merge pull request from GHSA-7rq4-qcpw-74gq

* Merge pull request from GHSA-rm89-9g65-4ffr

* Enable HTML escaping for all tables by default

* Enable HTML escaping for all tables by default

* Adds automatic escaping for bootstrap tables where custom formatter function is specified

- Intercept the row data *before* it is provided to the renderer function
- Adds a function for sanitizing nested data structure

* Sanitize form data before processing

(cherry picked from commit cd418d6948)

* Increment version number for release

* Fix sanitization for array case - was missing a return value
2022-06-15 20:43:43 +10:00
Oliver
f9c28eedaf Add error handling for case where user does not have git installed (#3179) (#3198)
(cherry picked from commit 5ecba6b13c)
2022-06-15 18:52:10 +10:00
Oliver
9bdbb0137f Adds release.yml file for auto-generation of release notes (#3194) 2022-06-14 20:30:09 +10:00
Oliver
412b464b09 Prevent auto-creation of SalesOrderShipment if we are importing data (#3170)
- Fixes a bug which prevents importing of datasets
2022-06-10 11:26:16 +10:00
Oliver
f48bd62534 Bump version number 2022-06-02 16:35:00 +10:00
Oliver
bd92ff1290 Fix filtering for purchaseorder table on supplierpart page (#3115) 2022-06-02 14:25:28 +10:00
Oliver
3b3238f762 Check user permissions before performing search (#3083)
* Check user permissions before performing search

* JS linting

(cherry picked from commit 6c7a80c141)
2022-05-27 13:27:28 +10:00
Oliver
81d29efc12 Improve error management for order price calculation (#3075)
* Improve error management for order price calculation

- If there are missing exchange rates, it throws an error
- Very much an edge case

* Style fixes

* Add warning message if total order price cannot be calculated

* price -> cost

(cherry picked from commit 640a5d0f24)
2022-05-27 13:27:22 +10:00
Oliver Walters
044315afbe Bump version number 2022-05-24 20:33:48 +10:00
42 changed files with 797 additions and 144 deletions

31
.github/release.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
# .github/release.yml
changelog:
exclude:
labels:
- translation
categories:
- title: Breaking Changes
labels:
- Semver-Major
- breaking
- title: Security Patches
labels:
- security
- title: New Features
labels:
- Semver-Minor
- enhancement
- title: Bug Fixes
labels:
- Semver-Patch
- bug
- title: Devops / Setup Changes
labels:
- docker
- setup
- demo
- CI
- title: Other Changes
labels:
- "*"

View File

@@ -77,8 +77,8 @@ jobs:
python check_js_templates.py python check_js_templates.py
- name: Lint Javascript Files - name: Lint Javascript Files
run: | run: |
invoke render-js-files python InvenTree/manage.py prerender
npx eslint js_tmp/*.js npx eslint InvenTree/InvenTree/static_i18n/i18n/*.js
html: html:
name: html template files name: html template files

31
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
# Runs on releases
name: Publish release notes
on:
release:
types: [published]
jobs:
tweet:
runs-on: ubuntu-latest
steps:
- uses: ethomson/send-tweet-action@v1
with:
status: "InvenTree release ${{ github.event.release.tag_name }} is out now! Release notes: ${{ github.event.release.html_url }} #opensource #inventree"
consumer-key: ${{ secrets.TWITTER_CONSUMER_API_KEY }}
consumer-secret: ${{ secrets.TWITTER_CONSUMER_API_SECRET }}
access-token: ${{ secrets.TWITTER_ACCESS_TOKEN }}
access-token-secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
reddit:
runs-on: ubuntu-latest
steps:
- uses: bluwy/release-for-reddit-action@v1
with:
username: ${{ secrets.REDDIT_USERNAME }}
password: ${{ secrets.REDDIT_PASSWORD }}
app-id: ${{ secrets.REDDIT_APP_ID }}
app-secret: ${{ secrets.REDDIT_APP_SECRET }}
subreddit: InvenTree
title: "InvenTree version ${{ github.event.release.tag_name }} released"
comment: "${{ github.event.release.body }}"

View File

@@ -0,0 +1,33 @@
"""Admin classes"""
from import_export.resources import ModelResource
class InvenTreeResource(ModelResource):
"""Custom subclass of the ModelResource class provided by django-import-export"
Ensures that exported data are escaped to prevent malicious formula injection.
Ref: https://owasp.org/www-community/attacks/CSV_Injection
"""
def export_resource(self, obj):
"""Custom function to override default row export behaviour.
Specifically, strip illegal leading characters to prevent formula injection
"""
row = super().export_resource(obj)
illegal_start_vals = ['@', '=', '+', '-', '@', '\t', '\r', '\n']
for idx, val in enumerate(row):
if type(val) is str:
val = val.strip()
# If the value starts with certain 'suspicious' values, remove it!
while len(val) > 0 and val[0] in illegal_start_vals:
# Remove the first character
val = val[1:]
row[idx] = val
return row

View File

@@ -17,6 +17,7 @@ from allauth.account.forms import SignupForm, set_form_field_order
from allauth.exceptions import ImmediateHttpResponse from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth_2fa.adapter import OTPAdapter from allauth_2fa.adapter import OTPAdapter
from allauth_2fa.forms import TOTPDeviceRemoveForm
from allauth_2fa.utils import user_has_valid_totp_device from allauth_2fa.utils import user_has_valid_totp_device
from crispy_forms.bootstrap import (AppendedText, Div, PrependedAppendedText, from crispy_forms.bootstrap import (AppendedText, Div, PrependedAppendedText,
PrependedText, StrictButton) PrependedText, StrictButton)
@@ -325,3 +326,36 @@ class CustomSocialAccountAdapter(RegistratonMixin, DefaultSocialAccountAdapter):
# Otherwise defer to the original allauth adapter. # Otherwise defer to the original allauth adapter.
return super().login(request, user) return super().login(request, user)
# Temporary fix for django-allauth-2fa # TODO remove
# See https://github.com/inventree/InvenTree/security/advisories/GHSA-8j76-mm54-52xq
class CustomTOTPDeviceRemoveForm(TOTPDeviceRemoveForm):
"""Custom Form to ensure a token is provided before removing MFA"""
# User must input a valid token so 2FA can be removed
token = forms.CharField(
label=_('Token'),
)
def __init__(self, user, **kwargs):
"""Add token field."""
super().__init__(user, **kwargs)
self.fields['token'].widget.attrs.update(
{
'autofocus': 'autofocus',
'autocomplete': 'off',
}
)
def clean_token(self):
"""Ensure at least one valid token is provided."""
# Ensure that the user has provided a valid token
token = self.cleaned_data.get('token')
# Verify that the user has provided a valid token
for device in self.user.totpdevice_set.filter(confirmed=True):
if device.verify_token(token):
return token
raise forms.ValidationError(_("The entered token is not valid"))

View File

@@ -217,18 +217,6 @@ logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
INSTALLED_APPS = [ INSTALLED_APPS = [
# Core django modules
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'user_sessions', # db user sessions
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
# Maintenance
'maintenance_mode',
# InvenTree apps # InvenTree apps
'build.apps.BuildConfig', 'build.apps.BuildConfig',
'common.apps.CommonConfig', 'common.apps.CommonConfig',
@@ -242,6 +230,18 @@ INSTALLED_APPS = [
'plugin.apps.PluginAppConfig', 'plugin.apps.PluginAppConfig',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last 'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
# Core django modules
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'user_sessions', # db user sessions
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
# Maintenance
'maintenance_mode',
# Third part add-ons # Third part add-ons
'django_filters', # Extended filter functionality 'django_filters', # Extended filter functionality
'rest_framework', # DRF (Django Rest Framework) 'rest_framework', # DRF (Django Rest Framework)
@@ -522,7 +522,7 @@ if "mysql" in db_engine: # pragma: no cover
# https://docs.djangoproject.com/en/3.2/ref/databases/#mysql-isolation-level # https://docs.djangoproject.com/en/3.2/ref/databases/#mysql-isolation-level
if "isolation_level" not in db_options: if "isolation_level" not in db_options:
serializable = _is_true( serializable = _is_true(
os.getenv("INVENTREE_DB_ISOLATION_SERIALIZABLE", "true") os.getenv("INVENTREE_DB_ISOLATION_SERIALIZABLE", "false")
) )
db_options["isolation_level"] = ( db_options["isolation_level"] = (
"serializable" if serializable else "read committed" "serializable" if serializable else "read committed"

View File

@@ -13,6 +13,7 @@
inventreeDocReady, inventreeDocReady,
inventreeLoad, inventreeLoad,
inventreeSave, inventreeSave,
sanitizeData,
*/ */
function attachClipboard(selector, containerselector, textElement) { function attachClipboard(selector, containerselector, textElement) {
@@ -273,6 +274,42 @@ function loadBrandIcon(element, name) {
} }
} }
/*
* Function to sanitize a (potentially nested) object.
* Iterates through all levels, and sanitizes each primitive string.
*
* Note that this function effectively provides a "deep copy" of the provided data,
* and the original data structure is unaltered.
*/
function sanitizeData(data) {
if (data == null) {
return null;
} else if (Array.isArray(data)) {
// Handle arrays
var arr = [];
data.forEach(function(val) {
arr.push(sanitizeData(val));
});
return arr;
} else if (typeof(data) === 'object') {
// Handle nested structures
var nested = {};
$.each(data, function(k, v) {
nested[k] = sanitizeData(v);
});
return nested;
} else if (typeof(data) === 'string') {
// Perform string replacement
return data.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;').replace(/`/g, '&#x60;');
} else {
return data;
}
}
// Convenience function to determine if an element exists // Convenience function to determine if an element exists
$.fn.exists = function() { $.fn.exists = function() {
return this.length !== 0; return this.length !== 0;

View File

@@ -36,9 +36,10 @@ from .views import (AppearanceSelectView, CurrencyRefreshView,
CustomConnectionsView, CustomEmailView, CustomConnectionsView, CustomEmailView,
CustomPasswordResetFromKeyView, CustomPasswordResetFromKeyView,
CustomSessionDeleteOtherView, CustomSessionDeleteView, CustomSessionDeleteOtherView, CustomSessionDeleteView,
DatabaseStatsView, DynamicJsView, EditUserView, IndexView, CustomTwoFactorRemove, DatabaseStatsView, DynamicJsView,
NotificationsView, SearchView, SetPasswordView, EditUserView, IndexView, NotificationsView, SearchView,
SettingCategorySelectView, SettingsView, auth_request) SetPasswordView, SettingCategorySelectView, SettingsView,
auth_request)
admin.site.site_header = "InvenTree Admin" admin.site.site_header = "InvenTree Admin"
@@ -169,6 +170,11 @@ frontendpatterns = [
re_path(r'^accounts/email/', CustomEmailView.as_view(), name='account_email'), re_path(r'^accounts/email/', CustomEmailView.as_view(), name='account_email'),
re_path(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'), re_path(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'),
re_path(r"^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"), re_path(r"^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"),
# Temporary fix for django-allauth-2fa # TODO remove
# See https://github.com/inventree/InvenTree/security/advisories/GHSA-8j76-mm54-52xq
re_path(r'^accounts/two_factor/remove/?$', CustomTwoFactorRemove.as_view(), name='two-factor-remove'),
re_path(r'^accounts/', include('allauth_2fa.urls')), # MFA support re_path(r'^accounts/', include('allauth_2fa.urls')), # MFA support
re_path(r'^accounts/', include('allauth.urls')), # included urlpatterns re_path(r'^accounts/', include('allauth.urls')), # included urlpatterns
] ]

View File

@@ -12,7 +12,7 @@ import common.models
from InvenTree.api_version import INVENTREE_API_VERSION from InvenTree.api_version import INVENTREE_API_VERSION
# InvenTree software version # InvenTree software version
INVENTREE_SW_VERSION = "0.7.0 dev" INVENTREE_SW_VERSION = "0.7.7"
def inventreeInstanceName(): def inventreeInstanceName():

View File

@@ -27,6 +27,7 @@ from allauth.account.models import EmailAddress
from allauth.account.views import EmailView, PasswordResetFromKeyView from allauth.account.views import EmailView, PasswordResetFromKeyView
from allauth.socialaccount.forms import DisconnectForm from allauth.socialaccount.forms import DisconnectForm
from allauth.socialaccount.views import ConnectionsView from allauth.socialaccount.views import ConnectionsView
from allauth_2fa.views import TwoFactorRemove
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
@@ -35,8 +36,8 @@ from common.settings import currency_code_default, currency_codes
from part.models import PartCategory from part.models import PartCategory
from users.models import RuleSet, check_user_role from users.models import RuleSet, check_user_role
from .forms import (DeleteForm, EditUserForm, SetPasswordForm, from .forms import (CustomTOTPDeviceRemoveForm, DeleteForm, EditUserForm,
SettingCategorySelectForm) SetPasswordForm, SettingCategorySelectForm)
from .helpers import str2bool from .helpers import str2bool
@@ -880,3 +881,12 @@ class NotificationsView(TemplateView):
""" """
template_name = "InvenTree/notifications/notifications.html" template_name = "InvenTree/notifications/notifications.html"
# Temporary fix for django-allauth-2fa # TODO remove
# See https://github.com/inventree/InvenTree/security/advisories/GHSA-8j76-mm54-52xq
class CustomTwoFactorRemove(TwoFactorRemove):
"""Use custom form."""
form_class = CustomTOTPDeviceRemoveForm
success_url = reverse_lazy("settings")

View File

@@ -2,16 +2,15 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field from import_export.fields import Field
from import_export.resources import ModelResource
import import_export.widgets as widgets import import_export.widgets as widgets
from build.models import Build, BuildItem from build.models import Build, BuildItem
from InvenTree.admin import InvenTreeResource
import part.models import part.models
class BuildResource(ModelResource): class BuildResource(InvenTreeResource):
"""Class for managing import/export of Build data""" """Class for managing import/export of Build data."""
# For some reason, we need to specify the fields individually for this ModelResource, # For some reason, we need to specify the fields individually for this ModelResource,
# but we don't for other ones. # but we don't for other ones.
# TODO: 2022-05-12 - Need to investigate why this is the case! # TODO: 2022-05-12 - Need to investigate why this is the case!

View File

@@ -24,6 +24,10 @@ def build_refs(apps, schema_editor):
except: # pragma: no cover except: # pragma: no cover
ref = 0 ref = 0
# Clip integer value to ensure it does not overflow database field
if ref > 0x7fffffff:
ref = 0x7fffffff
build.reference_int = ref build.reference_int = ref
build.save() build.save()

View File

@@ -3,8 +3,8 @@ from django.contrib import admin
import import_export.widgets as widgets import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field from import_export.fields import Field
from import_export.resources import ModelResource
from InvenTree.admin import InvenTreeResource
from part.models import Part from part.models import Part
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment, from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
@@ -12,8 +12,8 @@ from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
SupplierPriceBreak) SupplierPriceBreak)
class CompanyResource(ModelResource): class CompanyResource(InvenTreeResource):
""" Class for managing Company data import/export """ """Class for managing Company data import/export."""
class Meta: class Meta:
model = Company model = Company
@@ -34,10 +34,8 @@ class CompanyAdmin(ImportExportModelAdmin):
] ]
class SupplierPartResource(ModelResource): class SupplierPartResource(InvenTreeResource):
""" """Class for managing SupplierPart data import/export."""
Class for managing SupplierPart data import/export
"""
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
@@ -70,10 +68,8 @@ class SupplierPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'supplier', 'manufacturer_part',) autocomplete_fields = ('part', 'supplier', 'manufacturer_part',)
class ManufacturerPartResource(ModelResource): class ManufacturerPartResource(InvenTreeResource):
""" """Class for managing ManufacturerPart data import/export."""
Class for managing ManufacturerPart data import/export
"""
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
@@ -118,10 +114,8 @@ class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
autocomplete_fields = ('manufacturer_part',) autocomplete_fields = ('manufacturer_part',)
class ManufacturerPartParameterResource(ModelResource): class ManufacturerPartParameterResource(InvenTreeResource):
""" """Class for managing ManufacturerPartParameter data import/export."""
Class for managing ManufacturerPartParameter data import/export
"""
class Meta: class Meta:
model = ManufacturerPartParameter model = ManufacturerPartParameter
@@ -148,8 +142,8 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
autocomplete_fields = ('manufacturer_part',) autocomplete_fields = ('manufacturer_part',)
class SupplierPriceBreakResource(ModelResource): class SupplierPriceBreakResource(InvenTreeResource):
""" Class for managing SupplierPriceBreak data import/export """ """Class for managing SupplierPriceBreak data import/export."""
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart)) part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))

View File

@@ -365,6 +365,7 @@ class SupplierPartList(generics.ListCreateAPIView):
'part__IPN', 'part__IPN',
'part__name', 'part__name',
'part__description', 'part__description',
'part__keywords',
] ]

View File

@@ -309,7 +309,9 @@ $('#new-price-break').click(function() {
}); });
loadPurchaseOrderTable($("#purchase-order-table"), { loadPurchaseOrderTable($("#purchase-order-table"), {
url: "{% url 'api-po-list' %}?supplier_part={{ part.id }}", params: {
supplier_part: {{ part.id }},
}
}); });
loadStockTable($("#stock-table"), { loadStockTable($("#stock-table"), {

View File

@@ -1,9 +1,12 @@
"""Admin functionality for the 'order' app"""
from django.contrib import admin from django.contrib import admin
import import_export.widgets as widgets import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field from import_export.fields import Field
from import_export.resources import ModelResource
from InvenTree.admin import InvenTreeResource
from .models import (PurchaseOrder, PurchaseOrderExtraLine, from .models import (PurchaseOrder, PurchaseOrderExtraLine,
PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation, PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation,
@@ -13,6 +16,7 @@ from .models import (PurchaseOrder, PurchaseOrderExtraLine,
# region general classes # region general classes
class GeneralExtraLineAdmin: class GeneralExtraLineAdmin:
"""Admin class template for the 'ExtraLineItem' models"""
list_display = ( list_display = (
'order', 'order',
'quantity', 'quantity',
@@ -29,6 +33,7 @@ class GeneralExtraLineAdmin:
class GeneralExtraLineMeta: class GeneralExtraLineMeta:
"""Metaclass template for the 'ExtraLineItem' models"""
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instances = True clean_model_instances = True
@@ -36,11 +41,13 @@ class GeneralExtraLineMeta:
class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
"""Inline admin class for the PurchaseOrderLineItem model"""
model = PurchaseOrderLineItem model = PurchaseOrderLineItem
extra = 0 extra = 0
class PurchaseOrderAdmin(ImportExportModelAdmin): class PurchaseOrderAdmin(ImportExportModelAdmin):
"""Admin class for the PurchaseOrder model"""
exclude = [ exclude = [
'reference_int', 'reference_int',
@@ -68,6 +75,7 @@ class PurchaseOrderAdmin(ImportExportModelAdmin):
class SalesOrderAdmin(ImportExportModelAdmin): class SalesOrderAdmin(ImportExportModelAdmin):
"""Admin class for the SalesOrder model"""
exclude = [ exclude = [
'reference_int', 'reference_int',
@@ -90,10 +98,8 @@ class SalesOrderAdmin(ImportExportModelAdmin):
autocomplete_fields = ('customer',) autocomplete_fields = ('customer',)
class PurchaseOrderResource(ModelResource): class PurchaseOrderResource(InvenTreeResource):
""" """Class for managing import / export of PurchaseOrder data."""
Class for managing import / export of PurchaseOrder data
"""
# Add number of line items # Add number of line items
line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True) line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True)
@@ -102,6 +108,7 @@ class PurchaseOrderResource(ModelResource):
overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True) overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True)
class Meta: class Meta:
"""Metaclass"""
model = PurchaseOrder model = PurchaseOrder
skip_unchanged = True skip_unchanged = True
clean_model_instances = True clean_model_instances = True
@@ -110,8 +117,8 @@ class PurchaseOrderResource(ModelResource):
] ]
class PurchaseOrderLineItemResource(ModelResource): class PurchaseOrderLineItemResource(InvenTreeResource):
""" Class for managing import / export of PurchaseOrderLineItem data """ """Class for managing import / export of PurchaseOrderLineItem data."""
part_name = Field(attribute='part__part__name', readonly=True) part_name = Field(attribute='part__part__name', readonly=True)
@@ -122,23 +129,24 @@ class PurchaseOrderLineItemResource(ModelResource):
SKU = Field(attribute='part__SKU', readonly=True) SKU = Field(attribute='part__SKU', readonly=True)
class Meta: class Meta:
"""Metaclass"""
model = PurchaseOrderLineItem model = PurchaseOrderLineItem
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instances = True clean_model_instances = True
class PurchaseOrderExtraLineResource(ModelResource): class PurchaseOrderExtraLineResource(InvenTreeResource):
""" Class for managing import / export of PurchaseOrderExtraLine data """ """Class for managing import / export of PurchaseOrderExtraLine data."""
class Meta(GeneralExtraLineMeta): class Meta(GeneralExtraLineMeta):
"""Metaclass options."""
model = PurchaseOrderExtraLine model = PurchaseOrderExtraLine
class SalesOrderResource(ModelResource): class SalesOrderResource(InvenTreeResource):
""" """Class for managing import / export of SalesOrder data."""
Class for managing import / export of SalesOrder data
"""
# Add number of line items # Add number of line items
line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True) line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True)
@@ -147,6 +155,7 @@ class SalesOrderResource(ModelResource):
overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True) overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True)
class Meta: class Meta:
"""Metaclass options"""
model = SalesOrder model = SalesOrder
skip_unchanged = True skip_unchanged = True
clean_model_instances = True clean_model_instances = True
@@ -155,10 +164,8 @@ class SalesOrderResource(ModelResource):
] ]
class SalesOrderLineItemResource(ModelResource): class SalesOrderLineItemResource(InvenTreeResource):
""" """Class for managing import / export of SalesOrderLineItem data."""
Class for managing import / export of SalesOrderLineItem data
"""
part_name = Field(attribute='part__name', readonly=True) part_name = Field(attribute='part__name', readonly=True)
@@ -169,31 +176,34 @@ class SalesOrderLineItemResource(ModelResource):
fulfilled = Field(attribute='fulfilled_quantity', readonly=True) fulfilled = Field(attribute='fulfilled_quantity', readonly=True)
def dehydrate_sale_price(self, item): def dehydrate_sale_price(self, item):
""" """Return a string value of the 'sale_price' field, rather than the 'Money' object.
Return a string value of the 'sale_price' field, rather than the 'Money' object.
Ref: https://github.com/inventree/InvenTree/issues/2207 Ref: https://github.com/inventree/InvenTree/issues/2207
""" """
if item.sale_price: if item.sale_price:
return str(item.sale_price) return str(item.sale_price)
else: else:
return '' return ''
class Meta: class Meta:
"""Metaclass options"""
model = SalesOrderLineItem model = SalesOrderLineItem
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instances = True clean_model_instances = True
class SalesOrderExtraLineResource(ModelResource): class SalesOrderExtraLineResource(InvenTreeResource):
""" Class for managing import / export of SalesOrderExtraLine data """ """Class for managing import / export of SalesOrderExtraLine data."""
class Meta(GeneralExtraLineMeta): class Meta(GeneralExtraLineMeta):
"""Metaclass options."""
model = SalesOrderExtraLine model = SalesOrderExtraLine
class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
"""Admin class for the PurchaseOrderLine model"""
resource_class = PurchaseOrderLineItemResource resource_class = PurchaseOrderLineItemResource
@@ -210,11 +220,12 @@ class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
class PurchaseOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): class PurchaseOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin):
"""Admin class for the PurchaseOrderExtraLine model"""
resource_class = PurchaseOrderExtraLineResource resource_class = PurchaseOrderExtraLineResource
class SalesOrderLineItemAdmin(ImportExportModelAdmin): class SalesOrderLineItemAdmin(ImportExportModelAdmin):
"""Admin class for the SalesOrderLine model"""
resource_class = SalesOrderLineItemResource resource_class = SalesOrderLineItemResource
@@ -236,11 +247,12 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin):
class SalesOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): class SalesOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin):
"""Admin class for the SalesOrderExtraLine model"""
resource_class = SalesOrderExtraLineResource resource_class = SalesOrderExtraLineResource
class SalesOrderShipmentAdmin(ImportExportModelAdmin): class SalesOrderShipmentAdmin(ImportExportModelAdmin):
"""Admin class for the SalesOrderShipment model"""
list_display = [ list_display = [
'order', 'order',
@@ -258,6 +270,7 @@ class SalesOrderShipmentAdmin(ImportExportModelAdmin):
class SalesOrderAllocationAdmin(ImportExportModelAdmin): class SalesOrderAllocationAdmin(ImportExportModelAdmin):
"""Admin class for the SalesOrderAllocation model"""
list_display = ( list_display = (
'line', 'line',

View File

@@ -523,7 +523,7 @@ class PurchaseOrderLineItemList(APIDownloadMixin, generics.ListCreateAPIView):
search_fields = [ search_fields = [
'part__part__name', 'part__part__name',
'part__part__description', 'part__part__description',
'part__MPN', 'part__manufacturer_part__MPN',
'part__SKU', 'part__SKU',
'reference', 'reference',
] ]

View File

@@ -23,6 +23,10 @@ def build_refs(apps, schema_editor):
except: # pragma: no cover except: # pragma: no cover
ref = 0 ref = 0
# Clip integer value to ensure it does not overflow database field
if ref > 0x7fffffff:
ref = 0x7fffffff
order.reference_int = ref order.reference_int = ref
order.save() order.save()
@@ -40,6 +44,10 @@ def build_refs(apps, schema_editor):
except: # pragma: no cover except: # pragma: no cover
ref = 0 ref = 0
# Clip integer value to ensure it does not overflow database field
if ref > 0x7fffffff:
ref = 0x7fffffff
order.reference_int = ref order.reference_int = ref
order.save() order.save()

View File

@@ -4,7 +4,10 @@ Order model definitions
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging
import os import os
import sys
import traceback
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
@@ -19,12 +22,15 @@ from django.dispatch.dispatcher import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money from djmoney.money import Money
from error_report.models import Error
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
from mptt.models import TreeForeignKey from mptt.models import TreeForeignKey
import InvenTree.helpers import InvenTree.helpers
import InvenTree.ready
from common.settings import currency_code_default from common.settings import currency_code_default
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
@@ -38,6 +44,8 @@ from plugin.models import MetadataMixin
from stock import models as stock_models from stock import models as stock_models
from users import models as UserModels from users import models as UserModels
logger = logging.getLogger('inventree')
def get_next_po_number(): def get_next_po_number():
""" """
@@ -151,23 +159,74 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes')) notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes'))
def get_total_price(self): def get_total_price(self, target_currency=currency_code_default()):
""" """
Calculates the total price of all order lines Calculates the total price of all order lines, and converts to the specified target currency.
If not specified, the default system currency is used.
If currency conversion fails (e.g. there are no valid conversion rates),
then we simply return zero, rather than attempting some other calculation.
""" """
target_currency = currency_code_default()
total = Money(0, target_currency) total = Money(0, target_currency)
# gather name reference # gather name reference
price_ref = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price' price_ref_tag = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price'
# order items
total += sum(a.quantity * convert_money(getattr(a, price_ref), target_currency) for a in self.lines.all() if getattr(a, price_ref))
# extra lines # order items
total += sum(a.quantity * convert_money(a.price, target_currency) for a in self.extra_lines.all() if a.price) for line in self.lines.all():
price_ref = getattr(line, price_ref_tag)
if not price_ref:
continue
try:
total += line.quantity * convert_money(price_ref, target_currency)
except MissingRate:
# Record the error, try to press on
kind, info, data = sys.exc_info()
Error.objects.create(
kind=kind.__name__,
info=info,
data='\n'.join(traceback.format_exception(kind, info, data)),
path='order.get_total_price',
)
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# extra items
for line in self.extra_lines.all():
if not line.price:
continue
try:
total += line.quantity * convert_money(line.price, target_currency)
except MissingRate:
# Record the error, try to press on
kind, info, data = sys.exc_info()
Error.objects.create(
kind=kind.__name__,
info=info,
data='\n'.join(traceback.format_exception(kind, info, data)),
path='order.get_total_price',
)
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# set decimal-places # set decimal-places
total.decimal_places = 4 total.decimal_places = 4
return total return total
@@ -809,9 +868,19 @@ class SalesOrder(Order):
@receiver(post_save, sender=SalesOrder, dispatch_uid='build_post_save_log') @receiver(post_save, sender=SalesOrder, dispatch_uid='build_post_save_log')
def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs): def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs):
"""Callback function to be executed after a SalesOrder instance is saved.
- If the SALESORDER_DEFAULT_SHIPMENT setting is enabled, create a default shipment
- Ignore if the database is not ready for access
- Ignore if data import is active
""" """
Callback function to be executed after a SalesOrder instance is saved
""" if not InvenTree.ready.canAppAccessDatabase(allow_test=True):
return
if InvenTree.ready.isImportingData():
return
if created and getSetting('SALESORDER_DEFAULT_SHIPMENT'): if created and getSetting('SALESORDER_DEFAULT_SHIPMENT'):
# A new SalesOrder has just been created # A new SalesOrder has just been created

View File

@@ -181,7 +181,15 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-dollar-sign'></span></td> <td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total cost" %}</td> <td>{% trans "Total cost" %}</td>
<td id="poTotalPrice">{{ order.get_total_price }}</td> <td id="poTotalPrice">
{% with order.get_total_price as tp %}
{% if tp == None %}
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
{% else %}
{{ tp }}
{% endif %}
{% endwith %}
</td>
</tr> </tr>
</table> </table>
{% endblock %} {% endblock %}

View File

@@ -188,7 +188,15 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-dollar-sign'></span></td> <td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total cost" %}</td> <td>{% trans "Total cost" %}</td>
<td id="soTotalPrice">{{ order.get_total_price }}</td> <td id="soTotalPrice">
{% with order.get_total_price as tp %}
{% if tp == None %}
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
{% else %}
{{ tp }}
{% endif %}
{% endwith %}
</td>
</tr> </tr>
</table> </table>
{% endblock %} {% endblock %}

View File

@@ -56,6 +56,19 @@ class TestRefIntMigrations(MigratorTestCase):
with self.assertRaises(AttributeError): with self.assertRaises(AttributeError):
print(sales_order.reference_int) print(sales_order.reference_int)
# Create orders with very large reference values
self.po_pk = PurchaseOrder.objects.create(
supplier=supplier,
reference='999999999999999999999999999999999',
description='Big reference field',
).pk
self.so_pk = SalesOrder.objects.create(
customer=supplier,
reference='999999999999999999999999999999999',
description='Big reference field',
).pk
def test_ref_field(self): def test_ref_field(self):
""" """
Test that the 'reference_int' field has been created and is filled out correctly Test that the 'reference_int' field has been created and is filled out correctly
@@ -73,6 +86,15 @@ class TestRefIntMigrations(MigratorTestCase):
self.assertEqual(po.reference_int, ii) self.assertEqual(po.reference_int, ii)
self.assertEqual(so.reference_int, ii) self.assertEqual(so.reference_int, ii)
# Tests for orders with overly large reference values
po = PurchaseOrder.objects.get(pk=self.po_pk)
self.assertEqual(po.reference, '999999999999999999999999999999999')
self.assertEqual(po.reference_int, 0x7fffffff)
so = SalesOrder.objects.get(pk=self.so_pk)
self.assertEqual(so.reference, '999999999999999999999999999999999')
self.assertEqual(so.reference_int, 0x7fffffff)
class TestShipmentMigration(MigratorTestCase): class TestShipmentMigration(MigratorTestCase):
""" """

View File

@@ -3,15 +3,15 @@ from django.contrib import admin
import import_export.widgets as widgets import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field from import_export.fields import Field
from import_export.resources import ModelResource
import part.models as models import part.models as models
from company.models import SupplierPart from company.models import SupplierPart
from InvenTree.admin import InvenTreeResource
from stock.models import StockLocation from stock.models import StockLocation
class PartResource(ModelResource): class PartResource(InvenTreeResource):
""" Class for managing Part data import/export """ """Class for managing Part data import/export."""
# ForeignKey fields # ForeignKey fields
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory)) category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory))
@@ -81,8 +81,8 @@ class PartAdmin(ImportExportModelAdmin):
] ]
class PartCategoryResource(ModelResource): class PartCategoryResource(InvenTreeResource):
""" Class for managing PartCategory data import/export """ """Class for managing PartCategory data import/export."""
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory)) parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
@@ -157,8 +157,8 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
autocomplete_fields = ('part',) autocomplete_fields = ('part',)
class BomItemResource(ModelResource): class BomItemResource(InvenTreeResource):
""" Class for managing BomItem data import/export """ """Class for managing BomItem data import/export."""
level = Field(attribute='level', readonly=True) level = Field(attribute='level', readonly=True)
@@ -269,8 +269,8 @@ class ParameterTemplateAdmin(ImportExportModelAdmin):
search_fields = ('name', 'units') search_fields = ('name', 'units')
class ParameterResource(ModelResource): class ParameterResource(InvenTreeResource):
""" Class for managing PartParameter data import/export """ """Class for managing PartParameter data import/export."""
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part)) part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))

View File

@@ -966,7 +966,7 @@
{% if bom_parts %} {% if bom_parts %}
var bom_colors = randomColor({hue: 'green', count: {{ bom_parts|length }} }) var bom_colors = randomColor({hue: 'green', count: {{ bom_parts|length }} })
var bomdata = { var bomdata = {
labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}], labels: [{% for line in bom_parts %}'{{ line.name|escapejs }}',{% endfor %}],
datasets: [ datasets: [
{ {
label: 'Price', label: 'Price',

View File

@@ -0,0 +1,121 @@
"""This module provides custom translation tags specifically for use with javascript code.
Translated strings are escaped, such that they can be used as string literals in a javascript file.
"""
import django.templatetags.i18n
from django import template
from django.template import TemplateSyntaxError
from django.templatetags.i18n import TranslateNode
import bleach
register = template.Library()
class CustomTranslateNode(TranslateNode):
"""Custom translation node class, which sanitizes the translated strings for javascript use"""
def render(self, context):
"""Custom render function overrides / extends default behaviour"""
result = super().render(context)
result = bleach.clean(result)
# Remove any escape sequences
for seq in ['\a', '\b', '\f', '\n', '\r', '\t', '\v']:
result = result.replace(seq, '')
# Remove other disallowed characters
for c in ['\\', '`', ';', '|', '&']:
result = result.replace(c, '')
# Escape any quotes contained in the string
result = result.replace("'", r"\'")
result = result.replace('"', r'\"')
# Return the 'clean' resulting string
return result
@register.tag("translate")
@register.tag("trans")
def do_translate(parser, token):
"""Custom translation function, lifted from https://github.com/django/django/blob/main/django/templatetags/i18n.py
The only difference is that we pass this to our custom rendering node class
"""
bits = token.split_contents()
if len(bits) < 2:
raise TemplateSyntaxError("'%s' takes at least one argument" % bits[0])
message_string = parser.compile_filter(bits[1])
remaining = bits[2:]
noop = False
asvar = None
message_context = None
seen = set()
invalid_context = {"as", "noop"}
while remaining:
option = remaining.pop(0)
if option in seen:
raise TemplateSyntaxError(
"The '%s' option was specified more than once." % option,
)
elif option == "noop":
noop = True
elif option == "context":
try:
value = remaining.pop(0)
except IndexError:
raise TemplateSyntaxError(
"No argument provided to the '%s' tag for the context option."
% bits[0]
)
if value in invalid_context:
raise TemplateSyntaxError(
"Invalid argument '%s' provided to the '%s' tag for the context "
"option" % (value, bits[0]),
)
message_context = parser.compile_filter(value)
elif option == "as":
try:
value = remaining.pop(0)
except IndexError:
raise TemplateSyntaxError(
"No argument provided to the '%s' tag for the as option." % bits[0]
)
asvar = value
else:
raise TemplateSyntaxError(
"Unknown argument for '%s' tag: '%s'. The only options "
"available are 'noop', 'context' \"xxx\", and 'as VAR'."
% (
bits[0],
option,
)
)
seen.add(option)
return CustomTranslateNode(message_string, noop, asvar, message_context)
# Re-register tags which we have not explicitly overridden
register.tag("blocktrans", django.templatetags.i18n.do_block_translate)
register.tag("blocktranslate", django.templatetags.i18n.do_block_translate)
register.tag("language", django.templatetags.i18n.language)
register.tag("get_available_languages", django.templatetags.i18n.do_get_available_languages)
register.tag("get_language_info", django.templatetags.i18n.do_get_language_info)
register.tag("get_language_info_list", django.templatetags.i18n.do_get_language_info_list)
register.tag("get_current_language", django.templatetags.i18n.do_get_current_language)
register.tag("get_current_language_bidi", django.templatetags.i18n.do_get_current_language_bidi)
register.filter("language_name", django.templatetags.i18n.language_name)
register.filter("language_name_translated", django.templatetags.i18n.language_name_translated)
register.filter("language_name_local", django.templatetags.i18n.language_name_local)
register.filter("language_bidi", django.templatetags.i18n.language_bidi)

View File

@@ -467,6 +467,10 @@ class APICallMixin:
if endpoint_is_url: if endpoint_is_url:
url = endpoint url = endpoint
else: else:
if endpoint.startswith('/'):
endpoint = endpoint[1:]
url = f'{self.api_url}/{endpoint}' url = f'{self.api_url}/{endpoint}'
# build kwargs for call # build kwargs for call
@@ -474,6 +478,7 @@ class APICallMixin:
'url': url, 'url': url,
'headers': headers, 'headers': headers,
} }
if data: if data:
kwargs['data'] = json.dumps(data) kwargs['data'] = json.dumps(data)

View File

@@ -1,4 +1,4 @@
""" Unit tests for base mixins for plugins """ """Unit tests for base mixins for plugins."""
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase
@@ -17,7 +17,10 @@ from plugin.urls import PLUGIN_BASE
class BaseMixinDefinition: class BaseMixinDefinition:
"""Mixin to test the meta functions of all mixins."""
def test_mixin_name(self): def test_mixin_name(self):
"""Test that the mixin registers itseld correctly."""
# mixin name # mixin name
self.assertIn(self.MIXIN_NAME, [item['key'] for item in self.mixin.registered_mixins]) self.assertIn(self.MIXIN_NAME, [item['key'] for item in self.mixin.registered_mixins])
# human name # human name
@@ -25,6 +28,8 @@ class BaseMixinDefinition:
class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase): class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase):
"""Tests for SettingsMixin."""
MIXIN_HUMAN_NAME = 'Settings' MIXIN_HUMAN_NAME = 'Settings'
MIXIN_NAME = 'settings' MIXIN_NAME = 'settings'
MIXIN_ENABLE_CHECK = 'has_settings' MIXIN_ENABLE_CHECK = 'has_settings'
@@ -32,6 +37,7 @@ class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase):
TEST_SETTINGS = {'SETTING1': {'default': '123', }} TEST_SETTINGS = {'SETTING1': {'default': '123', }}
def setUp(self): def setUp(self):
"""Setup for all tests."""
class SettingsCls(SettingsMixin, InvenTreePlugin): class SettingsCls(SettingsMixin, InvenTreePlugin):
SETTINGS = self.TEST_SETTINGS SETTINGS = self.TEST_SETTINGS
self.mixin = SettingsCls() self.mixin = SettingsCls()
@@ -43,6 +49,7 @@ class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase):
super().setUp() super().setUp()
def test_function(self): def test_function(self):
"""Test that the mixin functions."""
# settings variable # settings variable
self.assertEqual(self.mixin.settings, self.TEST_SETTINGS) self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
@@ -60,11 +67,14 @@ class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase):
class UrlsMixinTest(BaseMixinDefinition, TestCase): class UrlsMixinTest(BaseMixinDefinition, TestCase):
"""Tests for UrlsMixin."""
MIXIN_HUMAN_NAME = 'URLs' MIXIN_HUMAN_NAME = 'URLs'
MIXIN_NAME = 'urls' MIXIN_NAME = 'urls'
MIXIN_ENABLE_CHECK = 'has_urls' MIXIN_ENABLE_CHECK = 'has_urls'
def setUp(self): def setUp(self):
"""Setup for all tests."""
class UrlsCls(UrlsMixin, InvenTreePlugin): class UrlsCls(UrlsMixin, InvenTreePlugin):
def test(): def test():
return 'ccc' return 'ccc'
@@ -76,6 +86,7 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
self.mixin_nothing = NoUrlsCls() self.mixin_nothing = NoUrlsCls()
def test_function(self): def test_function(self):
"""Test that the mixin functions."""
plg_name = self.mixin.plugin_name() plg_name = self.mixin.plugin_name()
# base_url # base_url
@@ -99,26 +110,32 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
class AppMixinTest(BaseMixinDefinition, TestCase): class AppMixinTest(BaseMixinDefinition, TestCase):
"""Tests for AppMixin."""
MIXIN_HUMAN_NAME = 'App registration' MIXIN_HUMAN_NAME = 'App registration'
MIXIN_NAME = 'app' MIXIN_NAME = 'app'
MIXIN_ENABLE_CHECK = 'has_app' MIXIN_ENABLE_CHECK = 'has_app'
def setUp(self): def setUp(self):
"""Setup for all tests."""
class TestCls(AppMixin, InvenTreePlugin): class TestCls(AppMixin, InvenTreePlugin):
pass pass
self.mixin = TestCls() self.mixin = TestCls()
def test_function(self): def test_function(self):
# test that this plugin is in settings """Test that the sample plugin registers in settings."""
self.assertIn('plugin.samples.integration', settings.INSTALLED_APPS) self.assertIn('plugin.samples.integration', settings.INSTALLED_APPS)
class NavigationMixinTest(BaseMixinDefinition, TestCase): class NavigationMixinTest(BaseMixinDefinition, TestCase):
"""Tests for NavigationMixin."""
MIXIN_HUMAN_NAME = 'Navigation Links' MIXIN_HUMAN_NAME = 'Navigation Links'
MIXIN_NAME = 'navigation' MIXIN_NAME = 'navigation'
MIXIN_ENABLE_CHECK = 'has_naviation' MIXIN_ENABLE_CHECK = 'has_naviation'
def setUp(self): def setUp(self):
"""Setup for all tests."""
class NavigationCls(NavigationMixin, InvenTreePlugin): class NavigationCls(NavigationMixin, InvenTreePlugin):
NAVIGATION = [ NAVIGATION = [
{'name': 'aa', 'link': 'plugin:test:test_view'}, {'name': 'aa', 'link': 'plugin:test:test_view'},
@@ -131,6 +148,7 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
self.nothing_mixin = NothingNavigationCls() self.nothing_mixin = NothingNavigationCls()
def test_function(self): def test_function(self):
"""Test that a correct configuration functions."""
# check right configuration # check right configuration
self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}, ]) self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}, ])
@@ -139,7 +157,7 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
self.assertEqual(self.nothing_mixin.navigation_name, '') self.assertEqual(self.nothing_mixin.navigation_name, '')
def test_fail(self): def test_fail(self):
# check wrong links fails """Test that wrong links fail."""
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
class NavigationCls(NavigationMixin, InvenTreePlugin): class NavigationCls(NavigationMixin, InvenTreePlugin):
NAVIGATION = ['aa', 'aa'] NAVIGATION = ['aa', 'aa']
@@ -147,11 +165,15 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
class APICallMixinTest(BaseMixinDefinition, TestCase): class APICallMixinTest(BaseMixinDefinition, TestCase):
"""Tests for APICallMixin."""
MIXIN_HUMAN_NAME = 'API calls' MIXIN_HUMAN_NAME = 'API calls'
MIXIN_NAME = 'api_call' MIXIN_NAME = 'api_call'
MIXIN_ENABLE_CHECK = 'has_api_call' MIXIN_ENABLE_CHECK = 'has_api_call'
def setUp(self): def setUp(self):
"""Setup for all tests."""
class MixinCls(APICallMixin, SettingsMixin, InvenTreePlugin): class MixinCls(APICallMixin, SettingsMixin, InvenTreePlugin):
NAME = "Sample API Caller" NAME = "Sample API Caller"
@@ -163,40 +185,47 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
'API_URL': { 'API_URL': {
'name': 'External URL', 'name': 'External URL',
'description': 'Where is your API located?', 'description': 'Where is your API located?',
'default': 'reqres.in', 'default': 'https://api.github.com',
}, },
} }
API_URL_SETTING = 'API_URL' API_URL_SETTING = 'API_URL'
API_TOKEN_SETTING = 'API_TOKEN' API_TOKEN_SETTING = 'API_TOKEN'
@property
def api_url(self):
"""Override API URL for this test"""
return "https://api.github.com"
def get_external_url(self, simple: bool = True): def get_external_url(self, simple: bool = True):
''' """Returns data from the sample endpoint."""
returns data from the sample endpoint return self.api_call('orgs/inventree', simple_response=simple)
'''
return self.api_call('api/users/2', simple_response=simple)
self.mixin = MixinCls() self.mixin = MixinCls()
class WrongCLS(APICallMixin, InvenTreePlugin): class WrongCLS(APICallMixin, InvenTreePlugin):
pass pass
self.mixin_wrong = WrongCLS() self.mixin_wrong = WrongCLS()
class WrongCLS2(APICallMixin, InvenTreePlugin): class WrongCLS2(APICallMixin, InvenTreePlugin):
API_URL_SETTING = 'test' API_URL_SETTING = 'test'
self.mixin_wrong2 = WrongCLS2() self.mixin_wrong2 = WrongCLS2()
def test_base_setup(self): def test_base_setup(self):
"""Test that the base settings work""" """Test that the base settings work."""
# check init # check init
self.assertTrue(self.mixin.has_api_call) self.assertTrue(self.mixin.has_api_call)
# api_url # api_url
self.assertEqual('https://reqres.in', self.mixin.api_url) self.assertEqual('https://api.github.com', self.mixin.api_url)
# api_headers # api_headers
headers = self.mixin.api_headers headers = self.mixin.api_headers
self.assertEqual(headers, {'Bearer': '', 'Content-Type': 'application/json'}) self.assertEqual(headers, {'Bearer': '', 'Content-Type': 'application/json'})
def test_args(self): def test_args(self):
"""Test that building up args work""" """Test that building up args work."""
# api_build_url_args # api_build_url_args
# 1 arg # 1 arg
result = self.mixin.api_build_url_args({'a': 'b'}) result = self.mixin.api_build_url_args({'a': 'b'})
@@ -209,11 +238,13 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
self.assertEqual(result, '?a=b&c=d,e,f') self.assertEqual(result, '?a=b&c=d,e,f')
def test_api_call(self): def test_api_call(self):
"""Test that api calls work""" """Test that api calls work."""
# api_call # api_call
result = self.mixin.get_external_url() result = self.mixin.get_external_url()
self.assertTrue(result) self.assertTrue(result)
self.assertIn('data', result,)
for key in ['login', 'email', 'name', 'twitter_username']:
self.assertIn(key, result)
# api_call without json conversion # api_call without json conversion
result = self.mixin.get_external_url(False) result = self.mixin.get_external_url(False)
@@ -221,25 +252,25 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
self.assertEqual(result.reason, 'OK') self.assertEqual(result.reason, 'OK')
# api_call with full url # api_call with full url
result = self.mixin.api_call('https://reqres.in/api/users/2', endpoint_is_url=True) result = self.mixin.api_call('https://api.github.com/orgs/inventree', endpoint_is_url=True)
self.assertTrue(result) self.assertTrue(result)
# api_call with post and data # api_call with post and data
result = self.mixin.api_call( result = self.mixin.api_call(
'api/users/', 'repos/inventree/InvenTree',
data={"name": "morpheus", "job": "leader"}, method='GET'
method='POST'
) )
self.assertTrue(result) self.assertTrue(result)
self.assertEqual(result['name'], 'morpheus') self.assertEqual(result['name'], 'InvenTree')
self.assertEqual(result['html_url'], 'https://github.com/inventree/InvenTree')
# api_call with filter # api_call with filter
result = self.mixin.api_call('api/users', url_args={'page': '2'}) result = self.mixin.api_call('repos/inventree/InvenTree/stargazers', url_args={'page': '2'})
self.assertTrue(result) self.assertTrue(result)
self.assertEqual(result['page'], 2)
def test_function_errors(self): def test_function_errors(self):
"""Test function errors""" """Test function errors."""
# wrongly defined plugins should not load # wrongly defined plugins should not load
with self.assertRaises(MixinNotImplementedError): with self.assertRaises(MixinNotImplementedError):
self.mixin_wrong.has_api_call() self.mixin_wrong.has_api_call()
@@ -250,7 +281,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
class PanelMixinTests(InvenTreeTestCase): class PanelMixinTests(InvenTreeTestCase):
"""Test that the PanelMixin plugin operates correctly""" """Test that the PanelMixin plugin operates correctly."""
fixtures = [ fixtures = [
'category', 'category',
@@ -262,8 +293,7 @@ class PanelMixinTests(InvenTreeTestCase):
roles = 'all' roles = 'all'
def test_installed(self): def test_installed(self):
"""Test that the sample panel plugin is installed""" """Test that the sample panel plugin is installed."""
plugins = registry.with_mixin('panel') plugins = registry.with_mixin('panel')
self.assertTrue(len(plugins) > 0) self.assertTrue(len(plugins) > 0)
@@ -275,8 +305,7 @@ class PanelMixinTests(InvenTreeTestCase):
self.assertEqual(len(plugins), 0) self.assertEqual(len(plugins), 0)
def test_disabled(self): def test_disabled(self):
"""Test that the panels *do not load* if the plugin is not enabled""" """Test that the panels *do not load* if the plugin is not enabled."""
plugin = registry.get_plugin('samplepanel') plugin = registry.get_plugin('samplepanel')
plugin.set_setting('ENABLE_HELLO_WORLD', True) plugin.set_setting('ENABLE_HELLO_WORLD', True)
@@ -305,10 +334,7 @@ class PanelMixinTests(InvenTreeTestCase):
self.assertNotIn('Custom Part Panel', str(response.content)) self.assertNotIn('Custom Part Panel', str(response.content))
def test_enabled(self): def test_enabled(self):
""" """Test that the panels *do* load if the plugin is enabled."""
Test that the panels *do* load if the plugin is enabled
"""
plugin = registry.get_plugin('samplepanel') plugin = registry.get_plugin('samplepanel')
self.assertEqual(len(registry.with_mixin('panel', active=True)), 0) self.assertEqual(len(registry.with_mixin('panel', active=True)), 0)
@@ -382,8 +408,7 @@ class PanelMixinTests(InvenTreeTestCase):
self.assertEqual(Error.objects.count(), n_errors + len(urls)) self.assertEqual(Error.objects.count(), n_errors + len(urls))
def test_mixin(self): def test_mixin(self):
"""Test that ImplementationError is raised""" """Test that ImplementationError is raised."""
with self.assertRaises(MixinNotImplementedError): with self.assertRaises(MixinNotImplementedError):
class Wrong(PanelMixin, InvenTreePlugin): class Wrong(PanelMixin, InvenTreePlugin):
pass pass

View File

@@ -114,6 +114,9 @@ def get_git_log(path):
output = output.split('\n') output = output.split('\n')
except subprocess.CalledProcessError: # pragma: no cover except subprocess.CalledProcessError: # pragma: no cover
pass pass
except FileNotFoundError: # pragma: no cover
# Most likely the system does not have 'git' installed
pass
if not output: if not output:
output = 7 * [''] # pragma: no cover output = 7 * [''] # pragma: no cover
@@ -129,6 +132,9 @@ def check_git_version():
output = str(subprocess.check_output(['git', '--version'], cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8') output = str(subprocess.check_output(['git', '--version'], cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')
except subprocess.CalledProcessError: # pragma: no cover except subprocess.CalledProcessError: # pragma: no cover
return False return False
except FileNotFoundError: # pragma: no cover
# Most likely the system does not have 'git' installed
return False
# process version string # process version string
try: try:

View File

@@ -307,7 +307,11 @@ class InvenTreePlugin(MixinBase, MetaBase):
""" """
if self._is_package: if self._is_package:
return self.__module__ # pragma: no cover return self.__module__ # pragma: no cover
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
try:
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
except ValueError:
return pathlib.Path(self.def_path)
@property @property
def settings_url(self): def settings_url(self):

View File

@@ -231,6 +231,9 @@ class PluginsRegistry:
except subprocess.CalledProcessError as error: # pragma: no cover except subprocess.CalledProcessError as error: # pragma: no cover
logger.error(f'Ran into error while trying to install plugins!\n{str(error)}') logger.error(f'Ran into error while trying to install plugins!\n{str(error)}')
return False return False
except FileNotFoundError: # pragma: no cover
# System most likely does not have 'git' installed
return False
logger.info(f'plugin requirements were run\n{output}') logger.info(f'plugin requirements were run\n{output}')

View File

@@ -3,10 +3,10 @@ from django.contrib import admin
import import_export.widgets as widgets import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field from import_export.fields import Field
from import_export.resources import ModelResource
from build.models import Build from build.models import Build
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from InvenTree.admin import InvenTreeResource
from order.models import PurchaseOrder, SalesOrder from order.models import PurchaseOrder, SalesOrder
from part.models import Part from part.models import Part
@@ -14,8 +14,8 @@ from .models import (StockItem, StockItemAttachment, StockItemTestResult,
StockItemTracking, StockLocation) StockItemTracking, StockLocation)
class LocationResource(ModelResource): class LocationResource(InvenTreeResource):
""" Class for managing StockLocation data import/export """ """Class for managing StockLocation data import/export."""
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockLocation)) parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockLocation))
@@ -65,8 +65,8 @@ class LocationAdmin(ImportExportModelAdmin):
] ]
class StockItemResource(ModelResource): class StockItemResource(InvenTreeResource):
""" Class for managing StockItem data import/export """ """Class for managing StockItem data import/export."""
# Custom managers for ForeignKey fields # Custom managers for ForeignKey fields
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))

View File

@@ -28,6 +28,9 @@ def update_serials(apps, schema_editor):
except: except:
serial = 0 serial = 0
# Ensure the integer value is not too large for the database field
if serial > 0x7fffffff:
serial = 0x7fffffff
item.serial_int = serial item.serial_int = serial
item.save() item.save()

View File

@@ -0,0 +1,69 @@
"""Unit tests for data migrations in the 'stock' app"""
from django_test_migrations.contrib.unittest_case import MigratorTestCase
from InvenTree import helpers
class TestSerialNumberMigration(MigratorTestCase):
"""Test data migration which updates serial numbers"""
migrate_from = ('stock', '0067_alter_stockitem_part')
migrate_to = ('stock', helpers.getNewestMigrationFile('stock'))
def prepare(self):
"""Create initial data for this migration"""
Part = self.old_state.apps.get_model('part', 'part')
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
# Create a base part
my_part = Part.objects.create(
name='PART-123',
description='Some part',
active=True,
trackable=True,
level=0,
tree_id=0,
lft=0, rght=0
)
# Create some serialized stock items
for sn in range(10, 20):
StockItem.objects.create(
part=my_part,
quantity=1,
serial=sn,
level=0,
tree_id=0,
lft=0, rght=0
)
# Create a stock item with a very large serial number
item = StockItem.objects.create(
part=my_part,
quantity=1,
serial='9999999999999999999999999999999999999999999999999999999999999',
level=0,
tree_id=0,
lft=0, rght=0
)
self.big_ref_pk = item.pk
def test_migrations(self):
"""Test that the migrations have been applied correctly"""
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
# Check that the serial number integer conversion has been applied correctly
for sn in range(10, 20):
item = StockItem.objects.get(serial_int=sn)
self.assertEqual(item.serial, str(sn))
big_ref_item = StockItem.objects.get(pk=self.big_ref_pk)
# Check that the StockItem maximum serial number
self.assertEqual(big_ref_item.serial, '9999999999999999999999999999999999999999999999999999999999999')
self.assertEqual(big_ref_item.serial_int, 0x7fffffff)

View File

@@ -149,7 +149,7 @@ function loadAttachmentTable(url, options) {
var html = `<span class='fas ${icon}'></span> ${filename}`; var html = `<span class='fas ${icon}'></span> ${filename}`;
return renderLink(html, value); return renderLink(html, value, {download: true});
} else if (row.link) { } else if (row.link) {
var html = `<span class='fas fa-link'></span> ${row.link}`; var html = `<span class='fas fa-link'></span> ${row.link}`;
return renderLink(html, row.link); return renderLink(html, row.link);

View File

@@ -204,6 +204,9 @@ function constructChangeForm(fields, options) {
}, },
success: function(data) { success: function(data) {
// Ensure the data are fully sanitized before we operate on it
data = sanitizeData(data);
// An optional function can be provided to process the returned results, // An optional function can be provided to process the returned results,
// before they are rendered to the form // before they are rendered to the form
if (options.processResults) { if (options.processResults) {

View File

@@ -266,6 +266,11 @@ function setupNotesField(element, url, options={}) {
initialValue: initial, initialValue: initial,
toolbar: toolbar_icons, toolbar: toolbar_icons,
shortcuts: [], shortcuts: [],
renderingConfig: {
markedOptions: {
sanitize: true,
}
}
}); });

View File

@@ -17,6 +17,41 @@ function closeSearchPanel() {
} }
// Keep track of the roles / permissions available to the current user
var search_user_roles = null;
/*
* Check if the user has the specified role and permission
*/
function checkPermission(role, permission='view') {
if (!search_user_roles) {
return false;
}
if (!(role in search_user_roles)) {
return false;
}
var roles = search_user_roles[role];
if (!roles) {
return false;
}
var found = false;
search_user_roles[role].forEach(function(p) {
if (String(p).valueOf() == String(permission).valueOf()) {
found = true;
}
});
return found;
}
/* /*
* Callback when the search panel is opened. * Callback when the search panel is opened.
* Ensure the panel is in a known state * Ensure the panel is in a known state
@@ -27,6 +62,16 @@ function openSearchPanel() {
clearSearchResults(); clearSearchResults();
// Request user roles if we do not have them
if (search_user_roles == null) {
inventreeGet('{% url "api-user-roles" %}', {}, {
success: function(response) {
search_user_roles = response.roles || {};
}
});
}
// Callback for text input changed
panel.find('#search-input').on('keyup change', searchTextChanged); panel.find('#search-input').on('keyup change', searchTextChanged);
// Callback for "clear search" button // Callback for "clear search" button
@@ -84,7 +129,7 @@ function updateSearch() {
// Show the "searching" text // Show the "searching" text
$('#offcanvas-search').find('#search-pending').show(); $('#offcanvas-search').find('#search-pending').show();
if (user_settings.SEARCH_PREVIEW_SHOW_PARTS) { if (checkPermission('part') && user_settings.SEARCH_PREVIEW_SHOW_PARTS) {
var params = {}; var params = {};
@@ -106,7 +151,7 @@ function updateSearch() {
); );
} }
if (user_settings.SEARCH_PREVIEW_SHOW_CATEGORIES) { if (checkPermission('part_category') && user_settings.SEARCH_PREVIEW_SHOW_CATEGORIES) {
// Search for matching part categories // Search for matching part categories
addSearchQuery( addSearchQuery(
'category', 'category',
@@ -120,7 +165,7 @@ function updateSearch() {
); );
} }
if (user_settings.SEARCH_PREVIEW_SHOW_STOCK) { if (checkPermission('stock') && user_settings.SEARCH_PREVIEW_SHOW_STOCK) {
// Search for matching stock items // Search for matching stock items
var filters = { var filters = {
@@ -146,7 +191,7 @@ function updateSearch() {
); );
} }
if (user_settings.SEARCH_PREVIEW_SHOW_LOCATIONS) { if (checkPermission('stock_location') && user_settings.SEARCH_PREVIEW_SHOW_LOCATIONS) {
// Search for matching stock locations // Search for matching stock locations
addSearchQuery( addSearchQuery(
'location', 'location',
@@ -160,7 +205,7 @@ function updateSearch() {
); );
} }
if (user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) { if ((checkPermission('sales_order') || checkPermission('purchase_order')) && user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) {
// Search for matching companies // Search for matching companies
addSearchQuery( addSearchQuery(
'company', 'company',
@@ -174,7 +219,7 @@ function updateSearch() {
); );
} }
if (user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) { if (checkPermission('purchase_order') && user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) {
var filters = { var filters = {
supplier_detail: true, supplier_detail: true,
@@ -197,7 +242,7 @@ function updateSearch() {
); );
} }
if (user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) { if (checkPermission('sales_order') && user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) {
var filters = { var filters = {
customer_detail: true, customer_detail: true,

View File

@@ -1306,7 +1306,8 @@ function loadStockTestResultsTable(table, options) {
var html = value; var html = value;
if (row.attachment) { if (row.attachment) {
html += `<a href='${row.attachment}'><span class='fas fa-file-alt float-right'></span></a>`; var text = `<span class='fas fa-file-alt float-right'></span>`;
html += renderLink(text, row.attachment, {download: true});
} }
return html; return html;

View File

@@ -92,6 +92,13 @@ function renderLink(text, url, options={}) {
var max_length = options.max_length || -1; var max_length = options.max_length || -1;
var extra = '';
if (options.download) {
var fn = url.split('/').at(-1);
extra += ` download='${fn}'`;
}
// Shorten the displayed length if required // Shorten the displayed length if required
if ((max_length > 0) && (text.length > max_length)) { if ((max_length > 0) && (text.length > max_length)) {
var slice_length = (max_length - 3) / 2; var slice_length = (max_length - 3) / 2;
@@ -102,7 +109,7 @@ function renderLink(text, url, options={}) {
text = `${text_start}...${text_end}`; text = `${text_start}...${text_end}`;
} }
return '<a href="' + url + '">' + text + '</a>'; return `<a href='${url}'${extra}>${text}</a>`;
} }
@@ -282,6 +289,8 @@ $.fn.inventreeTable = function(options) {
// Extract query params // Extract query params
var filters = options.queryParams || options.filters || {}; var filters = options.queryParams || options.filters || {};
options.escape = true;
// Store the total set of query params // Store the total set of query params
options.query_params = filters; options.query_params = filters;
@@ -468,6 +477,49 @@ function customGroupSorter(sortName, sortOrder, sortData) {
$.extend($.fn.bootstrapTable.defaults, $.fn.bootstrapTable.locales['en-US-custom']); $.extend($.fn.bootstrapTable.defaults, $.fn.bootstrapTable.locales['en-US-custom']);
// Enable HTML escaping by default
$.fn.bootstrapTable.escape = true;
// Override the 'calculateObjectValue' function at bootstrap-table.js:3525
// Allows us to escape any nasty HTML tags which are rendered to the DOM
$.fn.bootstrapTable.utils._calculateObjectValue = $.fn.bootstrapTable.utils.calculateObjectValue;
$.fn.bootstrapTable.utils.calculateObjectValue = function escapeCellValue(self, name, args, defaultValue) {
var args_list = [];
if (args) {
args_list.push(args[0]);
if (name && typeof(name) === 'function' && name.name == 'formatter') {
/* This is a custom "formatter" function for a particular cell,
* which may side-step regular HTML escaping, and inject malicious code into the DOM.
*
* Here we have access to the 'args' supplied to the custom 'formatter' function,
* which are in the order:
* args = [value, row, index, field]
*
* 'row' is the one we are interested in
*/
var row = Object.assign({}, args[1]);
args_list.push(sanitizeData(row));
} else {
args_list.push(args[1]);
}
for (var ii = 2; ii < args.length; ii++) {
args_list.push(args[ii]);
}
}
var value = $.fn.bootstrapTable.utils._calculateObjectValue(self, name, args_list, defaultValue);
return value;
};
})(jQuery); })(jQuery);
$.extend($.fn.treegrid.defaults, { $.extend($.fn.treegrid.defaults, {

View File

@@ -9,7 +9,7 @@ invoke>=1.4.0 # Invoke build tool
psycopg2>=2.9.1 psycopg2>=2.9.1
mysqlclient>=2.0.3 mysqlclient>=2.0.3
pgcli>=3.1.0 pgcli>=3.1.0
mariadb>=1.0.7 mariadb>=1.0.7,<1.1.0
# gunicorn web server # gunicorn web server
gunicorn>=20.1.0 gunicorn>=20.1.0

View File

@@ -7,8 +7,8 @@ coverage==5.3 # Unit test coverage
coveralls==2.1.2 # Coveralls linking (for Travis) coveralls==2.1.2 # Coveralls linking (for Travis)
cryptography==3.4.8 # Cryptography support cryptography==3.4.8 # Cryptography support
django-admin-shell==0.1.2 # Python shell for the admin interface django-admin-shell==0.1.2 # Python shell for the admin interface
django-allauth==0.45.0 # SSO for external providers via OpenID django-allauth==0.48.0 # SSO for external providers via OpenID
django-allauth-2fa==0.8 # MFA / 2FA django-allauth-2fa==0.9 # MFA / 2FA # IMPORTANT: Do only change after reviewing GHSA-8j76-mm54-52xq
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
django-cors-headers==3.2.0 # CORS headers extension for DRF django-cors-headers==3.2.0 # CORS headers extension for DRF
django-crispy-forms==1.11.2 # Form helpers django-crispy-forms==1.11.2 # Form helpers

View File

@@ -94,6 +94,7 @@ def install(c):
print("Installing required python packages from 'requirements.txt'") print("Installing required python packages from 'requirements.txt'")
# Install required Python packages with PIP # Install required Python packages with PIP
c.run('pip3 install --upgrade pip')
c.run('pip3 install -U -r requirements.txt') c.run('pip3 install -U -r requirements.txt')
@@ -554,9 +555,9 @@ def test_translations(c):
# complie regex # complie regex
reg = re.compile( reg = re.compile(
r"[a-zA-Z0-9]{1}"+ # match any single letter and number r"[a-zA-Z0-9]{1}" + # match any single letter and number
r"(?![^{\(\<]*[}\)\>])"+ # that is not inside curly brackets, brackets or a tag r"(?![^{\(\<]*[}\)\>])" + # that is not inside curly brackets, brackets or a tag
r"(?<![^\%][^\(][)][a-z])"+ # that is not a specially formatted variable with singles r"(?<![^\%][^\(][)][a-z])" + # that is not a specially formatted variable with singles
r"(?![^\\][\n])" # that is not a newline r"(?![^\\][\n])" # that is not a newline
) )
last_string = '' last_string = ''