mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-18 12:56:31 -06:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
216e09664b | ||
|
|
a0813dd3c1 | ||
|
|
cb540ebe90 | ||
|
|
2ab1d989ae | ||
|
|
57cb769317 | ||
|
|
efc360f22f | ||
|
|
ffe66472fe | ||
|
|
c17b34a864 | ||
|
|
5d44811f98 | ||
|
|
49c61f74b1 | ||
|
|
a19d342800 | ||
|
|
32b81aa598 | ||
|
|
5196fd5546 | ||
|
|
f9aa5a60fd | ||
|
|
b9c6cd70d4 | ||
|
|
26bf51c20a | ||
|
|
f9c28eedaf | ||
|
|
9bdbb0137f | ||
|
|
412b464b09 | ||
|
|
f48bd62534 | ||
|
|
bd92ff1290 | ||
|
|
3b3238f762 | ||
|
|
81d29efc12 | ||
|
|
044315afbe |
31
.github/release.yml
vendored
Normal file
31
.github/release.yml
vendored
Normal 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:
|
||||
- "*"
|
||||
4
.github/workflows/qc_checks.yaml
vendored
4
.github/workflows/qc_checks.yaml
vendored
@@ -77,8 +77,8 @@ jobs:
|
||||
python check_js_templates.py
|
||||
- name: Lint Javascript Files
|
||||
run: |
|
||||
invoke render-js-files
|
||||
npx eslint js_tmp/*.js
|
||||
python InvenTree/manage.py prerender
|
||||
npx eslint InvenTree/InvenTree/static_i18n/i18n/*.js
|
||||
|
||||
html:
|
||||
name: html template files
|
||||
|
||||
31
.github/workflows/release.yml
vendored
Normal file
31
.github/workflows/release.yml
vendored
Normal 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 }}"
|
||||
33
InvenTree/InvenTree/admin.py
Normal file
33
InvenTree/InvenTree/admin.py
Normal 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
|
||||
@@ -17,6 +17,7 @@ from allauth.account.forms import SignupForm, set_form_field_order
|
||||
from allauth.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from allauth_2fa.adapter import OTPAdapter
|
||||
from allauth_2fa.forms import TOTPDeviceRemoveForm
|
||||
from allauth_2fa.utils import user_has_valid_totp_device
|
||||
from crispy_forms.bootstrap import (AppendedText, Div, PrependedAppendedText,
|
||||
PrependedText, StrictButton)
|
||||
@@ -325,3 +326,36 @@ class CustomSocialAccountAdapter(RegistratonMixin, DefaultSocialAccountAdapter):
|
||||
|
||||
# Otherwise defer to the original allauth adapter.
|
||||
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"))
|
||||
|
||||
@@ -217,18 +217,6 @@ logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||
|
||||
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
|
||||
'build.apps.BuildConfig',
|
||||
'common.apps.CommonConfig',
|
||||
@@ -242,6 +230,18 @@ INSTALLED_APPS = [
|
||||
'plugin.apps.PluginAppConfig',
|
||||
'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
|
||||
'django_filters', # Extended filter functionality
|
||||
'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
|
||||
if "isolation_level" not in db_options:
|
||||
serializable = _is_true(
|
||||
os.getenv("INVENTREE_DB_ISOLATION_SERIALIZABLE", "true")
|
||||
os.getenv("INVENTREE_DB_ISOLATION_SERIALIZABLE", "false")
|
||||
)
|
||||
db_options["isolation_level"] = (
|
||||
"serializable" if serializable else "read committed"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
inventreeDocReady,
|
||||
inventreeLoad,
|
||||
inventreeSave,
|
||||
sanitizeData,
|
||||
*/
|
||||
|
||||
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, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/`/g, '`');
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Convenience function to determine if an element exists
|
||||
$.fn.exists = function() {
|
||||
return this.length !== 0;
|
||||
|
||||
@@ -36,9 +36,10 @@ from .views import (AppearanceSelectView, CurrencyRefreshView,
|
||||
CustomConnectionsView, CustomEmailView,
|
||||
CustomPasswordResetFromKeyView,
|
||||
CustomSessionDeleteOtherView, CustomSessionDeleteView,
|
||||
DatabaseStatsView, DynamicJsView, EditUserView, IndexView,
|
||||
NotificationsView, SearchView, SetPasswordView,
|
||||
SettingCategorySelectView, SettingsView, auth_request)
|
||||
CustomTwoFactorRemove, DatabaseStatsView, DynamicJsView,
|
||||
EditUserView, IndexView, NotificationsView, SearchView,
|
||||
SetPasswordView, SettingCategorySelectView, SettingsView,
|
||||
auth_request)
|
||||
|
||||
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/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"),
|
||||
|
||||
# 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.urls')), # included urlpatterns
|
||||
]
|
||||
|
||||
@@ -12,7 +12,7 @@ import common.models
|
||||
from InvenTree.api_version import INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = "0.7.0 dev"
|
||||
INVENTREE_SW_VERSION = "0.7.7"
|
||||
|
||||
|
||||
def inventreeInstanceName():
|
||||
|
||||
@@ -27,6 +27,7 @@ from allauth.account.models import EmailAddress
|
||||
from allauth.account.views import EmailView, PasswordResetFromKeyView
|
||||
from allauth.socialaccount.forms import DisconnectForm
|
||||
from allauth.socialaccount.views import ConnectionsView
|
||||
from allauth_2fa.views import TwoFactorRemove
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
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 users.models import RuleSet, check_user_role
|
||||
|
||||
from .forms import (DeleteForm, EditUserForm, SetPasswordForm,
|
||||
SettingCategorySelectForm)
|
||||
from .forms import (CustomTOTPDeviceRemoveForm, DeleteForm, EditUserForm,
|
||||
SetPasswordForm, SettingCategorySelectForm)
|
||||
from .helpers import str2bool
|
||||
|
||||
|
||||
@@ -880,3 +881,12 @@ class NotificationsView(TemplateView):
|
||||
"""
|
||||
|
||||
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")
|
||||
|
||||
@@ -2,16 +2,15 @@ from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from import_export.fields import Field
|
||||
from import_export.resources import ModelResource
|
||||
import import_export.widgets as widgets
|
||||
|
||||
from build.models import Build, BuildItem
|
||||
|
||||
from InvenTree.admin import InvenTreeResource
|
||||
import part.models
|
||||
|
||||
|
||||
class BuildResource(ModelResource):
|
||||
"""Class for managing import/export of Build data"""
|
||||
class BuildResource(InvenTreeResource):
|
||||
"""Class for managing import/export of Build data."""
|
||||
# For some reason, we need to specify the fields individually for this ModelResource,
|
||||
# but we don't for other ones.
|
||||
# TODO: 2022-05-12 - Need to investigate why this is the case!
|
||||
|
||||
@@ -24,6 +24,10 @@ def build_refs(apps, schema_editor):
|
||||
except: # pragma: no cover
|
||||
ref = 0
|
||||
|
||||
# Clip integer value to ensure it does not overflow database field
|
||||
if ref > 0x7fffffff:
|
||||
ref = 0x7fffffff
|
||||
|
||||
build.reference_int = ref
|
||||
build.save()
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ from django.contrib import admin
|
||||
import import_export.widgets as widgets
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from import_export.fields import Field
|
||||
from import_export.resources import ModelResource
|
||||
|
||||
from InvenTree.admin import InvenTreeResource
|
||||
from part.models import Part
|
||||
|
||||
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
||||
@@ -12,8 +12,8 @@ from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
||||
SupplierPriceBreak)
|
||||
|
||||
|
||||
class CompanyResource(ModelResource):
|
||||
""" Class for managing Company data import/export """
|
||||
class CompanyResource(InvenTreeResource):
|
||||
"""Class for managing Company data import/export."""
|
||||
|
||||
class Meta:
|
||||
model = Company
|
||||
@@ -34,10 +34,8 @@ class CompanyAdmin(ImportExportModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
class SupplierPartResource(ModelResource):
|
||||
"""
|
||||
Class for managing SupplierPart data import/export
|
||||
"""
|
||||
class SupplierPartResource(InvenTreeResource):
|
||||
"""Class for managing SupplierPart data import/export."""
|
||||
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||
|
||||
@@ -70,10 +68,8 @@ class SupplierPartAdmin(ImportExportModelAdmin):
|
||||
autocomplete_fields = ('part', 'supplier', 'manufacturer_part',)
|
||||
|
||||
|
||||
class ManufacturerPartResource(ModelResource):
|
||||
"""
|
||||
Class for managing ManufacturerPart data import/export
|
||||
"""
|
||||
class ManufacturerPartResource(InvenTreeResource):
|
||||
"""Class for managing ManufacturerPart data import/export."""
|
||||
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||
|
||||
@@ -118,10 +114,8 @@ class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
|
||||
autocomplete_fields = ('manufacturer_part',)
|
||||
|
||||
|
||||
class ManufacturerPartParameterResource(ModelResource):
|
||||
"""
|
||||
Class for managing ManufacturerPartParameter data import/export
|
||||
"""
|
||||
class ManufacturerPartParameterResource(InvenTreeResource):
|
||||
"""Class for managing ManufacturerPartParameter data import/export."""
|
||||
|
||||
class Meta:
|
||||
model = ManufacturerPartParameter
|
||||
@@ -148,8 +142,8 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
|
||||
autocomplete_fields = ('manufacturer_part',)
|
||||
|
||||
|
||||
class SupplierPriceBreakResource(ModelResource):
|
||||
""" Class for managing SupplierPriceBreak data import/export """
|
||||
class SupplierPriceBreakResource(InvenTreeResource):
|
||||
"""Class for managing SupplierPriceBreak data import/export."""
|
||||
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))
|
||||
|
||||
|
||||
@@ -365,6 +365,7 @@ class SupplierPartList(generics.ListCreateAPIView):
|
||||
'part__IPN',
|
||||
'part__name',
|
||||
'part__description',
|
||||
'part__keywords',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -309,7 +309,9 @@ $('#new-price-break').click(function() {
|
||||
});
|
||||
|
||||
loadPurchaseOrderTable($("#purchase-order-table"), {
|
||||
url: "{% url 'api-po-list' %}?supplier_part={{ part.id }}",
|
||||
params: {
|
||||
supplier_part: {{ part.id }},
|
||||
}
|
||||
});
|
||||
|
||||
loadStockTable($("#stock-table"), {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Admin functionality for the 'order' app"""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
import import_export.widgets as widgets
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from import_export.fields import Field
|
||||
from import_export.resources import ModelResource
|
||||
|
||||
from InvenTree.admin import InvenTreeResource
|
||||
|
||||
from .models import (PurchaseOrder, PurchaseOrderExtraLine,
|
||||
PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation,
|
||||
@@ -13,6 +16,7 @@ from .models import (PurchaseOrder, PurchaseOrderExtraLine,
|
||||
|
||||
# region general classes
|
||||
class GeneralExtraLineAdmin:
|
||||
"""Admin class template for the 'ExtraLineItem' models"""
|
||||
list_display = (
|
||||
'order',
|
||||
'quantity',
|
||||
@@ -29,6 +33,7 @@ class GeneralExtraLineAdmin:
|
||||
|
||||
|
||||
class GeneralExtraLineMeta:
|
||||
"""Metaclass template for the 'ExtraLineItem' models"""
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
@@ -36,11 +41,13 @@ class GeneralExtraLineMeta:
|
||||
|
||||
|
||||
class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
|
||||
"""Inline admin class for the PurchaseOrderLineItem model"""
|
||||
model = PurchaseOrderLineItem
|
||||
extra = 0
|
||||
|
||||
|
||||
class PurchaseOrderAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the PurchaseOrder model"""
|
||||
|
||||
exclude = [
|
||||
'reference_int',
|
||||
@@ -68,6 +75,7 @@ class PurchaseOrderAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
class SalesOrderAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the SalesOrder model"""
|
||||
|
||||
exclude = [
|
||||
'reference_int',
|
||||
@@ -90,10 +98,8 @@ class SalesOrderAdmin(ImportExportModelAdmin):
|
||||
autocomplete_fields = ('customer',)
|
||||
|
||||
|
||||
class PurchaseOrderResource(ModelResource):
|
||||
"""
|
||||
Class for managing import / export of PurchaseOrder data
|
||||
"""
|
||||
class PurchaseOrderResource(InvenTreeResource):
|
||||
"""Class for managing import / export of PurchaseOrder data."""
|
||||
|
||||
# Add number of line items
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass"""
|
||||
model = PurchaseOrder
|
||||
skip_unchanged = True
|
||||
clean_model_instances = True
|
||||
@@ -110,8 +117,8 @@ class PurchaseOrderResource(ModelResource):
|
||||
]
|
||||
|
||||
|
||||
class PurchaseOrderLineItemResource(ModelResource):
|
||||
""" Class for managing import / export of PurchaseOrderLineItem data """
|
||||
class PurchaseOrderLineItemResource(InvenTreeResource):
|
||||
"""Class for managing import / export of PurchaseOrderLineItem data."""
|
||||
|
||||
part_name = Field(attribute='part__part__name', readonly=True)
|
||||
|
||||
@@ -122,23 +129,24 @@ class PurchaseOrderLineItemResource(ModelResource):
|
||||
SKU = Field(attribute='part__SKU', readonly=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass"""
|
||||
model = PurchaseOrderLineItem
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class PurchaseOrderExtraLineResource(ModelResource):
|
||||
""" Class for managing import / export of PurchaseOrderExtraLine data """
|
||||
class PurchaseOrderExtraLineResource(InvenTreeResource):
|
||||
"""Class for managing import / export of PurchaseOrderExtraLine data."""
|
||||
|
||||
class Meta(GeneralExtraLineMeta):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = PurchaseOrderExtraLine
|
||||
|
||||
|
||||
class SalesOrderResource(ModelResource):
|
||||
"""
|
||||
Class for managing import / export of SalesOrder data
|
||||
"""
|
||||
class SalesOrderResource(InvenTreeResource):
|
||||
"""Class for managing import / export of SalesOrder data."""
|
||||
|
||||
# Add number of line items
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
model = SalesOrder
|
||||
skip_unchanged = True
|
||||
clean_model_instances = True
|
||||
@@ -155,10 +164,8 @@ class SalesOrderResource(ModelResource):
|
||||
]
|
||||
|
||||
|
||||
class SalesOrderLineItemResource(ModelResource):
|
||||
"""
|
||||
Class for managing import / export of SalesOrderLineItem data
|
||||
"""
|
||||
class SalesOrderLineItemResource(InvenTreeResource):
|
||||
"""Class for managing import / export of SalesOrderLineItem data."""
|
||||
|
||||
part_name = Field(attribute='part__name', readonly=True)
|
||||
|
||||
@@ -169,31 +176,34 @@ class SalesOrderLineItemResource(ModelResource):
|
||||
fulfilled = Field(attribute='fulfilled_quantity', readonly=True)
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
if item.sale_price:
|
||||
return str(item.sale_price)
|
||||
else:
|
||||
return ''
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
model = SalesOrderLineItem
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class SalesOrderExtraLineResource(ModelResource):
|
||||
""" Class for managing import / export of SalesOrderExtraLine data """
|
||||
class SalesOrderExtraLineResource(InvenTreeResource):
|
||||
"""Class for managing import / export of SalesOrderExtraLine data."""
|
||||
|
||||
class Meta(GeneralExtraLineMeta):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = SalesOrderExtraLine
|
||||
|
||||
|
||||
class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the PurchaseOrderLine model"""
|
||||
|
||||
resource_class = PurchaseOrderLineItemResource
|
||||
|
||||
@@ -210,11 +220,12 @@ class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
class PurchaseOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin):
|
||||
|
||||
"""Admin class for the PurchaseOrderExtraLine model"""
|
||||
resource_class = PurchaseOrderExtraLineResource
|
||||
|
||||
|
||||
class SalesOrderLineItemAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the SalesOrderLine model"""
|
||||
|
||||
resource_class = SalesOrderLineItemResource
|
||||
|
||||
@@ -236,11 +247,12 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
class SalesOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin):
|
||||
|
||||
"""Admin class for the SalesOrderExtraLine model"""
|
||||
resource_class = SalesOrderExtraLineResource
|
||||
|
||||
|
||||
class SalesOrderShipmentAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the SalesOrderShipment model"""
|
||||
|
||||
list_display = [
|
||||
'order',
|
||||
@@ -258,6 +270,7 @@ class SalesOrderShipmentAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
class SalesOrderAllocationAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the SalesOrderAllocation model"""
|
||||
|
||||
list_display = (
|
||||
'line',
|
||||
|
||||
@@ -523,7 +523,7 @@ class PurchaseOrderLineItemList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||
search_fields = [
|
||||
'part__part__name',
|
||||
'part__part__description',
|
||||
'part__MPN',
|
||||
'part__manufacturer_part__MPN',
|
||||
'part__SKU',
|
||||
'reference',
|
||||
]
|
||||
|
||||
@@ -23,6 +23,10 @@ def build_refs(apps, schema_editor):
|
||||
except: # pragma: no cover
|
||||
ref = 0
|
||||
|
||||
# Clip integer value to ensure it does not overflow database field
|
||||
if ref > 0x7fffffff:
|
||||
ref = 0x7fffffff
|
||||
|
||||
order.reference_int = ref
|
||||
order.save()
|
||||
|
||||
@@ -40,6 +44,10 @@ def build_refs(apps, schema_editor):
|
||||
except: # pragma: no cover
|
||||
ref = 0
|
||||
|
||||
# Clip integer value to ensure it does not overflow database field
|
||||
if ref > 0x7fffffff:
|
||||
ref = 0x7fffffff
|
||||
|
||||
order.reference_int = ref
|
||||
order.save()
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ Order model definitions
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -19,12 +22,15 @@ from django.dispatch.dispatcher import receiver
|
||||
from django.urls import reverse
|
||||
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.money import Money
|
||||
from error_report.models import Error
|
||||
from markdownx.models import MarkdownxField
|
||||
from mptt.models import TreeForeignKey
|
||||
|
||||
import InvenTree.helpers
|
||||
import InvenTree.ready
|
||||
from common.settings import currency_code_default
|
||||
from company.models import Company, SupplierPart
|
||||
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
||||
@@ -38,6 +44,8 @@ from plugin.models import MetadataMixin
|
||||
from stock import models as stock_models
|
||||
from users import models as UserModels
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def get_next_po_number():
|
||||
"""
|
||||
@@ -151,23 +159,74 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||
|
||||
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)
|
||||
|
||||
# gather name reference
|
||||
price_ref = '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))
|
||||
price_ref_tag = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price'
|
||||
|
||||
# extra lines
|
||||
total += sum(a.quantity * convert_money(a.price, target_currency) for a in self.extra_lines.all() if a.price)
|
||||
# order items
|
||||
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
|
||||
total.decimal_places = 4
|
||||
|
||||
return total
|
||||
|
||||
|
||||
@@ -809,9 +868,19 @@ class SalesOrder(Order):
|
||||
|
||||
@receiver(post_save, sender=SalesOrder, dispatch_uid='build_post_save_log')
|
||||
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'):
|
||||
# A new SalesOrder has just been created
|
||||
|
||||
|
||||
@@ -181,7 +181,15 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-dollar-sign'></span></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>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
@@ -188,7 +188,15 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-dollar-sign'></span></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>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
@@ -56,6 +56,19 @@ class TestRefIntMigrations(MigratorTestCase):
|
||||
with self.assertRaises(AttributeError):
|
||||
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):
|
||||
"""
|
||||
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(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):
|
||||
"""
|
||||
|
||||
@@ -3,15 +3,15 @@ from django.contrib import admin
|
||||
import import_export.widgets as widgets
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from import_export.fields import Field
|
||||
from import_export.resources import ModelResource
|
||||
|
||||
import part.models as models
|
||||
from company.models import SupplierPart
|
||||
from InvenTree.admin import InvenTreeResource
|
||||
from stock.models import StockLocation
|
||||
|
||||
|
||||
class PartResource(ModelResource):
|
||||
""" Class for managing Part data import/export """
|
||||
class PartResource(InvenTreeResource):
|
||||
"""Class for managing Part data import/export."""
|
||||
|
||||
# ForeignKey fields
|
||||
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory))
|
||||
@@ -81,8 +81,8 @@ class PartAdmin(ImportExportModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
class PartCategoryResource(ModelResource):
|
||||
""" Class for managing PartCategory data import/export """
|
||||
class PartCategoryResource(InvenTreeResource):
|
||||
"""Class for managing PartCategory data import/export."""
|
||||
|
||||
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
|
||||
|
||||
@@ -157,8 +157,8 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
|
||||
autocomplete_fields = ('part',)
|
||||
|
||||
|
||||
class BomItemResource(ModelResource):
|
||||
""" Class for managing BomItem data import/export """
|
||||
class BomItemResource(InvenTreeResource):
|
||||
"""Class for managing BomItem data import/export."""
|
||||
|
||||
level = Field(attribute='level', readonly=True)
|
||||
|
||||
@@ -269,8 +269,8 @@ class ParameterTemplateAdmin(ImportExportModelAdmin):
|
||||
search_fields = ('name', 'units')
|
||||
|
||||
|
||||
class ParameterResource(ModelResource):
|
||||
""" Class for managing PartParameter data import/export """
|
||||
class ParameterResource(InvenTreeResource):
|
||||
"""Class for managing PartParameter data import/export."""
|
||||
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
|
||||
|
||||
|
||||
@@ -966,7 +966,7 @@
|
||||
{% if bom_parts %}
|
||||
var bom_colors = randomColor({hue: 'green', count: {{ bom_parts|length }} })
|
||||
var bomdata = {
|
||||
labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}],
|
||||
labels: [{% for line in bom_parts %}'{{ line.name|escapejs }}',{% endfor %}],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Price',
|
||||
|
||||
121
InvenTree/part/templatetags/i18n.py
Normal file
121
InvenTree/part/templatetags/i18n.py
Normal 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)
|
||||
@@ -467,6 +467,10 @@ class APICallMixin:
|
||||
if endpoint_is_url:
|
||||
url = endpoint
|
||||
else:
|
||||
|
||||
if endpoint.startswith('/'):
|
||||
endpoint = endpoint[1:]
|
||||
|
||||
url = f'{self.api_url}/{endpoint}'
|
||||
|
||||
# build kwargs for call
|
||||
@@ -474,6 +478,7 @@ class APICallMixin:
|
||||
'url': url,
|
||||
'headers': headers,
|
||||
}
|
||||
|
||||
if data:
|
||||
kwargs['data'] = json.dumps(data)
|
||||
|
||||
|
||||
@@ -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.test import TestCase
|
||||
@@ -17,7 +17,10 @@ from plugin.urls import PLUGIN_BASE
|
||||
|
||||
|
||||
class BaseMixinDefinition:
|
||||
"""Mixin to test the meta functions of all mixins."""
|
||||
|
||||
def test_mixin_name(self):
|
||||
"""Test that the mixin registers itseld correctly."""
|
||||
# mixin name
|
||||
self.assertIn(self.MIXIN_NAME, [item['key'] for item in self.mixin.registered_mixins])
|
||||
# human name
|
||||
@@ -25,6 +28,8 @@ class BaseMixinDefinition:
|
||||
|
||||
|
||||
class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase):
|
||||
"""Tests for SettingsMixin."""
|
||||
|
||||
MIXIN_HUMAN_NAME = 'Settings'
|
||||
MIXIN_NAME = 'settings'
|
||||
MIXIN_ENABLE_CHECK = 'has_settings'
|
||||
@@ -32,6 +37,7 @@ class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase):
|
||||
TEST_SETTINGS = {'SETTING1': {'default': '123', }}
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for all tests."""
|
||||
class SettingsCls(SettingsMixin, InvenTreePlugin):
|
||||
SETTINGS = self.TEST_SETTINGS
|
||||
self.mixin = SettingsCls()
|
||||
@@ -43,6 +49,7 @@ class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase):
|
||||
super().setUp()
|
||||
|
||||
def test_function(self):
|
||||
"""Test that the mixin functions."""
|
||||
# settings variable
|
||||
self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
|
||||
|
||||
@@ -60,11 +67,14 @@ class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase):
|
||||
|
||||
|
||||
class UrlsMixinTest(BaseMixinDefinition, TestCase):
|
||||
"""Tests for UrlsMixin."""
|
||||
|
||||
MIXIN_HUMAN_NAME = 'URLs'
|
||||
MIXIN_NAME = 'urls'
|
||||
MIXIN_ENABLE_CHECK = 'has_urls'
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for all tests."""
|
||||
class UrlsCls(UrlsMixin, InvenTreePlugin):
|
||||
def test():
|
||||
return 'ccc'
|
||||
@@ -76,6 +86,7 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
|
||||
self.mixin_nothing = NoUrlsCls()
|
||||
|
||||
def test_function(self):
|
||||
"""Test that the mixin functions."""
|
||||
plg_name = self.mixin.plugin_name()
|
||||
|
||||
# base_url
|
||||
@@ -99,26 +110,32 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
|
||||
|
||||
|
||||
class AppMixinTest(BaseMixinDefinition, TestCase):
|
||||
"""Tests for AppMixin."""
|
||||
|
||||
MIXIN_HUMAN_NAME = 'App registration'
|
||||
MIXIN_NAME = 'app'
|
||||
MIXIN_ENABLE_CHECK = 'has_app'
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for all tests."""
|
||||
class TestCls(AppMixin, InvenTreePlugin):
|
||||
pass
|
||||
self.mixin = TestCls()
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class NavigationMixinTest(BaseMixinDefinition, TestCase):
|
||||
"""Tests for NavigationMixin."""
|
||||
|
||||
MIXIN_HUMAN_NAME = 'Navigation Links'
|
||||
MIXIN_NAME = 'navigation'
|
||||
MIXIN_ENABLE_CHECK = 'has_naviation'
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for all tests."""
|
||||
class NavigationCls(NavigationMixin, InvenTreePlugin):
|
||||
NAVIGATION = [
|
||||
{'name': 'aa', 'link': 'plugin:test:test_view'},
|
||||
@@ -131,6 +148,7 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
|
||||
self.nothing_mixin = NothingNavigationCls()
|
||||
|
||||
def test_function(self):
|
||||
"""Test that a correct configuration functions."""
|
||||
# check right configuration
|
||||
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, '')
|
||||
|
||||
def test_fail(self):
|
||||
# check wrong links fails
|
||||
"""Test that wrong links fail."""
|
||||
with self.assertRaises(NotImplementedError):
|
||||
class NavigationCls(NavigationMixin, InvenTreePlugin):
|
||||
NAVIGATION = ['aa', 'aa']
|
||||
@@ -147,11 +165,15 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
|
||||
|
||||
|
||||
class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
"""Tests for APICallMixin."""
|
||||
|
||||
MIXIN_HUMAN_NAME = 'API calls'
|
||||
MIXIN_NAME = 'api_call'
|
||||
MIXIN_ENABLE_CHECK = 'has_api_call'
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for all tests."""
|
||||
|
||||
class MixinCls(APICallMixin, SettingsMixin, InvenTreePlugin):
|
||||
NAME = "Sample API Caller"
|
||||
|
||||
@@ -163,40 +185,47 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
'API_URL': {
|
||||
'name': 'External URL',
|
||||
'description': 'Where is your API located?',
|
||||
'default': 'reqres.in',
|
||||
'default': 'https://api.github.com',
|
||||
},
|
||||
}
|
||||
|
||||
API_URL_SETTING = 'API_URL'
|
||||
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):
|
||||
'''
|
||||
returns data from the sample endpoint
|
||||
'''
|
||||
return self.api_call('api/users/2', simple_response=simple)
|
||||
"""Returns data from the sample endpoint."""
|
||||
return self.api_call('orgs/inventree', simple_response=simple)
|
||||
|
||||
self.mixin = MixinCls()
|
||||
|
||||
class WrongCLS(APICallMixin, InvenTreePlugin):
|
||||
pass
|
||||
|
||||
self.mixin_wrong = WrongCLS()
|
||||
|
||||
class WrongCLS2(APICallMixin, InvenTreePlugin):
|
||||
API_URL_SETTING = 'test'
|
||||
|
||||
self.mixin_wrong2 = WrongCLS2()
|
||||
|
||||
def test_base_setup(self):
|
||||
"""Test that the base settings work"""
|
||||
"""Test that the base settings work."""
|
||||
# check init
|
||||
self.assertTrue(self.mixin.has_api_call)
|
||||
# api_url
|
||||
self.assertEqual('https://reqres.in', self.mixin.api_url)
|
||||
self.assertEqual('https://api.github.com', self.mixin.api_url)
|
||||
|
||||
# api_headers
|
||||
headers = self.mixin.api_headers
|
||||
self.assertEqual(headers, {'Bearer': '', 'Content-Type': 'application/json'})
|
||||
|
||||
def test_args(self):
|
||||
"""Test that building up args work"""
|
||||
"""Test that building up args work."""
|
||||
# api_build_url_args
|
||||
# 1 arg
|
||||
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')
|
||||
|
||||
def test_api_call(self):
|
||||
"""Test that api calls work"""
|
||||
"""Test that api calls work."""
|
||||
# api_call
|
||||
result = self.mixin.get_external_url()
|
||||
self.assertTrue(result)
|
||||
self.assertIn('data', result,)
|
||||
|
||||
for key in ['login', 'email', 'name', 'twitter_username']:
|
||||
self.assertIn(key, result)
|
||||
|
||||
# api_call without json conversion
|
||||
result = self.mixin.get_external_url(False)
|
||||
@@ -221,25 +252,25 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
self.assertEqual(result.reason, 'OK')
|
||||
|
||||
# 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)
|
||||
|
||||
# api_call with post and data
|
||||
result = self.mixin.api_call(
|
||||
'api/users/',
|
||||
data={"name": "morpheus", "job": "leader"},
|
||||
method='POST'
|
||||
'repos/inventree/InvenTree',
|
||||
method='GET'
|
||||
)
|
||||
|
||||
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
|
||||
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.assertEqual(result['page'], 2)
|
||||
|
||||
def test_function_errors(self):
|
||||
"""Test function errors"""
|
||||
"""Test function errors."""
|
||||
# wrongly defined plugins should not load
|
||||
with self.assertRaises(MixinNotImplementedError):
|
||||
self.mixin_wrong.has_api_call()
|
||||
@@ -250,7 +281,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
|
||||
|
||||
class PanelMixinTests(InvenTreeTestCase):
|
||||
"""Test that the PanelMixin plugin operates correctly"""
|
||||
"""Test that the PanelMixin plugin operates correctly."""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@@ -262,8 +293,7 @@ class PanelMixinTests(InvenTreeTestCase):
|
||||
roles = 'all'
|
||||
|
||||
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')
|
||||
|
||||
self.assertTrue(len(plugins) > 0)
|
||||
@@ -275,8 +305,7 @@ class PanelMixinTests(InvenTreeTestCase):
|
||||
self.assertEqual(len(plugins), 0)
|
||||
|
||||
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.set_setting('ENABLE_HELLO_WORLD', True)
|
||||
@@ -305,10 +334,7 @@ class PanelMixinTests(InvenTreeTestCase):
|
||||
self.assertNotIn('Custom Part Panel', str(response.content))
|
||||
|
||||
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')
|
||||
|
||||
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))
|
||||
|
||||
def test_mixin(self):
|
||||
"""Test that ImplementationError is raised"""
|
||||
|
||||
"""Test that ImplementationError is raised."""
|
||||
with self.assertRaises(MixinNotImplementedError):
|
||||
class Wrong(PanelMixin, InvenTreePlugin):
|
||||
pass
|
||||
|
||||
@@ -114,6 +114,9 @@ def get_git_log(path):
|
||||
output = output.split('\n')
|
||||
except subprocess.CalledProcessError: # pragma: no cover
|
||||
pass
|
||||
except FileNotFoundError: # pragma: no cover
|
||||
# Most likely the system does not have 'git' installed
|
||||
pass
|
||||
|
||||
if not output:
|
||||
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')
|
||||
except subprocess.CalledProcessError: # pragma: no cover
|
||||
return False
|
||||
except FileNotFoundError: # pragma: no cover
|
||||
# Most likely the system does not have 'git' installed
|
||||
return False
|
||||
|
||||
# process version string
|
||||
try:
|
||||
|
||||
@@ -307,7 +307,11 @@ class InvenTreePlugin(MixinBase, MetaBase):
|
||||
"""
|
||||
if self._is_package:
|
||||
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
|
||||
def settings_url(self):
|
||||
|
||||
@@ -231,6 +231,9 @@ class PluginsRegistry:
|
||||
except subprocess.CalledProcessError as error: # pragma: no cover
|
||||
logger.error(f'Ran into error while trying to install plugins!\n{str(error)}')
|
||||
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}')
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ from django.contrib import admin
|
||||
import import_export.widgets as widgets
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from import_export.fields import Field
|
||||
from import_export.resources import ModelResource
|
||||
|
||||
from build.models import Build
|
||||
from company.models import Company, SupplierPart
|
||||
from InvenTree.admin import InvenTreeResource
|
||||
from order.models import PurchaseOrder, SalesOrder
|
||||
from part.models import Part
|
||||
|
||||
@@ -14,8 +14,8 @@ from .models import (StockItem, StockItemAttachment, StockItemTestResult,
|
||||
StockItemTracking, StockLocation)
|
||||
|
||||
|
||||
class LocationResource(ModelResource):
|
||||
""" Class for managing StockLocation data import/export """
|
||||
class LocationResource(InvenTreeResource):
|
||||
"""Class for managing StockLocation data import/export."""
|
||||
|
||||
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockLocation))
|
||||
|
||||
@@ -65,8 +65,8 @@ class LocationAdmin(ImportExportModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
class StockItemResource(ModelResource):
|
||||
""" Class for managing StockItem data import/export """
|
||||
class StockItemResource(InvenTreeResource):
|
||||
"""Class for managing StockItem data import/export."""
|
||||
|
||||
# Custom managers for ForeignKey fields
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||
|
||||
@@ -28,6 +28,9 @@ def update_serials(apps, schema_editor):
|
||||
except:
|
||||
serial = 0
|
||||
|
||||
# Ensure the integer value is not too large for the database field
|
||||
if serial > 0x7fffffff:
|
||||
serial = 0x7fffffff
|
||||
|
||||
item.serial_int = serial
|
||||
item.save()
|
||||
|
||||
69
InvenTree/stock/test_migrations.py
Normal file
69
InvenTree/stock/test_migrations.py
Normal 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)
|
||||
@@ -149,7 +149,7 @@ function loadAttachmentTable(url, options) {
|
||||
|
||||
var html = `<span class='fas ${icon}'></span> ${filename}`;
|
||||
|
||||
return renderLink(html, value);
|
||||
return renderLink(html, value, {download: true});
|
||||
} else if (row.link) {
|
||||
var html = `<span class='fas fa-link'></span> ${row.link}`;
|
||||
return renderLink(html, row.link);
|
||||
|
||||
@@ -204,6 +204,9 @@ function constructChangeForm(fields, options) {
|
||||
},
|
||||
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,
|
||||
// before they are rendered to the form
|
||||
if (options.processResults) {
|
||||
|
||||
@@ -266,6 +266,11 @@ function setupNotesField(element, url, options={}) {
|
||||
initialValue: initial,
|
||||
toolbar: toolbar_icons,
|
||||
shortcuts: [],
|
||||
renderingConfig: {
|
||||
markedOptions: {
|
||||
sanitize: true,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
* Ensure the panel is in a known state
|
||||
@@ -27,6 +62,16 @@ function openSearchPanel() {
|
||||
|
||||
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);
|
||||
|
||||
// Callback for "clear search" button
|
||||
@@ -84,7 +129,7 @@ function updateSearch() {
|
||||
// Show the "searching" text
|
||||
$('#offcanvas-search').find('#search-pending').show();
|
||||
|
||||
if (user_settings.SEARCH_PREVIEW_SHOW_PARTS) {
|
||||
if (checkPermission('part') && user_settings.SEARCH_PREVIEW_SHOW_PARTS) {
|
||||
|
||||
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
|
||||
addSearchQuery(
|
||||
'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
|
||||
|
||||
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
|
||||
addSearchQuery(
|
||||
'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
|
||||
addSearchQuery(
|
||||
'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 = {
|
||||
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 = {
|
||||
customer_detail: true,
|
||||
|
||||
@@ -1306,7 +1306,8 @@ function loadStockTestResultsTable(table, options) {
|
||||
var html = value;
|
||||
|
||||
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;
|
||||
|
||||
@@ -92,6 +92,13 @@ function renderLink(text, url, options={}) {
|
||||
|
||||
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
|
||||
if ((max_length > 0) && (text.length > max_length)) {
|
||||
var slice_length = (max_length - 3) / 2;
|
||||
@@ -102,7 +109,7 @@ function renderLink(text, url, options={}) {
|
||||
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
|
||||
var filters = options.queryParams || options.filters || {};
|
||||
|
||||
options.escape = true;
|
||||
|
||||
// Store the total set of query params
|
||||
options.query_params = filters;
|
||||
|
||||
@@ -468,6 +477,49 @@ function customGroupSorter(sortName, sortOrder, sortData) {
|
||||
|
||||
$.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);
|
||||
|
||||
$.extend($.fn.treegrid.defaults, {
|
||||
|
||||
@@ -9,7 +9,7 @@ invoke>=1.4.0 # Invoke build tool
|
||||
psycopg2>=2.9.1
|
||||
mysqlclient>=2.0.3
|
||||
pgcli>=3.1.0
|
||||
mariadb>=1.0.7
|
||||
mariadb>=1.0.7,<1.1.0
|
||||
|
||||
# gunicorn web server
|
||||
gunicorn>=20.1.0
|
||||
|
||||
@@ -7,8 +7,8 @@ coverage==5.3 # Unit test coverage
|
||||
coveralls==2.1.2 # Coveralls linking (for Travis)
|
||||
cryptography==3.4.8 # Cryptography support
|
||||
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-2fa==0.8 # MFA / 2FA
|
||||
django-allauth==0.48.0 # SSO for external providers via OpenID
|
||||
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-cors-headers==3.2.0 # CORS headers extension for DRF
|
||||
django-crispy-forms==1.11.2 # Form helpers
|
||||
|
||||
7
tasks.py
7
tasks.py
@@ -94,6 +94,7 @@ def install(c):
|
||||
print("Installing required python packages from 'requirements.txt'")
|
||||
|
||||
# Install required Python packages with PIP
|
||||
c.run('pip3 install --upgrade pip')
|
||||
c.run('pip3 install -U -r requirements.txt')
|
||||
|
||||
|
||||
@@ -554,9 +555,9 @@ def test_translations(c):
|
||||
|
||||
# complie regex
|
||||
reg = re.compile(
|
||||
r"[a-zA-Z0-9]{1}"+ # match any single letter and number
|
||||
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-zA-Z0-9]{1}" + # match any single letter and number
|
||||
r"(?![^{\(\<]*[}\)\>])" + # that is not inside curly brackets, brackets or a tag
|
||||
r"(?<![^\%][^\(][)][a-z])" + # that is not a specially formatted variable with singles
|
||||
r"(?![^\\][\n])" # that is not a newline
|
||||
)
|
||||
last_string = ''
|
||||
|
||||
Reference in New Issue
Block a user