Merge commit '9e125be07e94e0997ce03fd905673c44aad81c00' into block-notes

This commit is contained in:
Oliver Walters
2026-06-18 01:05:06 +00:00
18 changed files with 290 additions and 41 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ name: Frontend
on: on:
push: push:
branches-ignore: ["l10*"] branches-ignore: ["l10*", "dependabot/*", "backport/*"]
pull_request: pull_request:
branches-ignore: ["l10*"] branches-ignore: ["l10*"]
+1 -1
View File
@@ -7,7 +7,7 @@ name: Import / Export
on: on:
push: push:
branches-ignore: ["l10*"] branches-ignore: ["l10*", "dependabot/*", "backport/*"]
pull_request: pull_request:
branches-ignore: ["l10*"] branches-ignore: ["l10*"]
+1 -1
View File
@@ -4,7 +4,7 @@ name: QC
on: on:
push: push:
branches-ignore: ["l10*"] branches-ignore: ["l10*", "dependabot/*", "backport/*"]
pull_request: pull_request:
branches-ignore: ["l10*"] branches-ignore: ["l10*"]
+37 -13
View File
@@ -42,6 +42,8 @@ jobs:
id-token: write id-token: write
contents: write contents: write
attestations: write attestations: write
artifact-metadata: write
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with: with:
@@ -73,7 +75,7 @@ jobs:
zip -r ../frontend-build.zip * .vite zip -r ../frontend-build.zip * .vite
- name: Attest Build Provenance - name: Attest Build Provenance
id: attest id: attest
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # pin@v1 uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # pin@v4
with: with:
subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip" subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip"
@@ -87,11 +89,15 @@ jobs:
with: with:
name: frontend-build name: frontend-build
path: src/backend/InvenTree/web/static/frontend-build.zip path: src/backend/InvenTree/web/static/frontend-build.zip
- name: Rename Attestation Bundle
run: |
mv ${BUNDLE_PATH} src/backend/InvenTree/web/static/frontend-build.intoto.jsonl
env:
BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path}}
- name: Upload Attestation - name: Upload Attestation
run: gh release upload ${REF} ${BUNDLE_PATH}#frontend-build.intoto.jsonl run: gh release upload ${REF} src/backend/InvenTree/web/static/frontend-build.intoto.jsonl#frontend-build.intoto.jsonl
env: env:
REF: ${{ github.ref_name }} REF: ${{ github.ref_name }}
BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path}}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docs: docs:
@@ -148,12 +154,12 @@ jobs:
- ubuntu:22.04 - ubuntu:22.04
- ubuntu:24.04 - ubuntu:24.04
- debian:12 - debian:12
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: Get frontend artifact - name: Get frontend artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pin@v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pin@v8.0.1
with: with:
@@ -209,7 +215,7 @@ jobs:
echo "calculate release channel" echo "calculate release channel"
pip install --require-hashes -r contrib/dev_reqs/requirements.txt pip install --require-hashes -r contrib/dev_reqs/requirements.txt
python3 .github/scripts/version_check.py python3 .github/scripts/version_check.py
- name: Package - name: Package - current release channel
uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main
id: package id: package
with: with:
@@ -235,14 +241,6 @@ jobs:
repository: inventree/InvenTree repository: inventree/InvenTree
channel: ${{ env.pkg_channel }} channel: ${{ env.pkg_channel }}
file: ${{ steps.package.outputs.package_path }} file: ${{ steps.package.outputs.package_path }}
- name: Publish to go.packager.io - stable release channel
uses: pkgr/action/publish@3bce081ae512c5020856e237d37b3f5479d4aa71 # pin@main
with:
target: ${{ matrix.target }}
token: ${{ secrets.PACKAGER_RELEASE_TOKEN }}
repository: inventree/InvenTree
channel: stable
file: ${{ steps.package.outputs.package_path }}
- name: Publish to artifact - name: Publish to artifact
run: gh release upload ${REF} ${PACKAGE_PATH}#${PACKAGE_NAME} run: gh release upload ${REF} ${PACKAGE_PATH}#${PACKAGE_NAME}
env: env:
@@ -250,3 +248,29 @@ jobs:
PACKAGE_PATH: ${{ steps.package.outputs.package_path }} PACKAGE_PATH: ${{ steps.package.outputs.package_path }}
PACKAGE_NAME: ${{ matrix.target }}-${{ steps.setup.outputs.version }}.tar.gz PACKAGE_NAME: ${{ matrix.target }}-${{ steps.setup.outputs.version }}.tar.gz
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Package - stable release channel
uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main
id: package-stable
with:
target: ${{ matrix.target }}
version: ${{ steps.setup.outputs.version }}
debug: true
cache_prefix: ${{ github.ref_name }}
env: |
INVENTREE_DB_ENGINE=sqlite3
INVENTREE_DB_NAME=database.sqlite3
INVENTREE_PLUGINS_ENABLED=true
INVENTREE_MEDIA_ROOT=/opt/inventree/media
INVENTREE_STATIC_ROOT=/opt/inventree/static
INVENTREE_BACKUP_DIR=/opt/inventree/backup
INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt
INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
APP_REPO=inventree/InvenTree
- name: Publish to go.packager.io - stable release channel
uses: pkgr/action/publish@3bce081ae512c5020856e237d37b3f5479d4aa71 # pin@main
with:
target: ${{ matrix.target }}
token: ${{ secrets.PACKAGER_RELEASE_TOKEN }}
repository: inventree/InvenTree
channel: stable
file: ${{ steps.package-stable.outputs.package_path }}
@@ -1,16 +1,22 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 508 INVENTREE_API_VERSION = 510
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v508 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/11971 v510 -> 2026-06-18 : https://github.com/inventree/InvenTree/pull/11971
- Removes direct "notes" field from any models which previously supported markdown notes - Removes direct "notes" field from any models which previously supported markdown notes
- Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship - Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship
- Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API - Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API
v509 -> 2026-06-17 : https://github.com/inventree/InvenTree/pull/12184
- Adds "completed_row_count_history" and "row_count_history" fields to the DataImportSession model, which store the historic count of completed rows and total rows for a data import session.
v508 -> 2026-06-17 : https://github.com/inventree/InvenTree/pull/11982
- An order's "status_custom_key" can be updated via PATCH API endpoint
v507 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/12180 v507 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/12180
- Adds "lookup_field" parameter to the DataImportSessionSerializer, which allows for more flexible lookup of related objects during data import operations - Adds "lookup_field" parameter to the DataImportSessionSerializer, which allows for more flexible lookup of related objects during data import operations
@@ -0,0 +1,23 @@
# Generated by Django 5.2.15 on 2026-06-17 05:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('importer', '0006_dataimportcolumnmap_lookup_field'),
]
operations = [
migrations.AddField(
model_name='dataimportsession',
name='completed_row_count_history',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Completed Row Count History'),
),
migrations.AddField(
model_name='dataimportsession',
name='row_count_history',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Row Count History'),
),
]
+14
View File
@@ -385,7 +385,13 @@ class DataImportSession(models.Model):
if self.status != DataImportStatusCode.COMPLETE.value: if self.status != DataImportStatusCode.COMPLETE.value:
self.status = DataImportStatusCode.COMPLETE.value self.status = DataImportStatusCode.COMPLETE.value
# persist historic count values for reporting purposes
self.completed_row_count_history = self.completed_row_count
self.row_count_history = self.row_count
self.save() self.save()
# Clear staging data now that all rows have been imported # Clear staging data now that all rows have been imported
self.rows.all().delete() self.rows.all().delete()
self.column_mappings.all().delete() self.column_mappings.all().delete()
@@ -402,6 +408,14 @@ class DataImportSession(models.Model):
"""Return the number of completed rows for this session.""" """Return the number of completed rows for this session."""
return self.rows.filter(complete=True).count() return self.rows.filter(complete=True).count()
# Historic values for reporting purposes
completed_row_count_history = models.PositiveIntegerField(
blank=True, null=True, verbose_name=_('Completed Row Count History')
)
row_count_history = models.PositiveIntegerField(
blank=True, null=True, verbose_name=_('Row Count History')
)
def available_fields(self): def available_fields(self):
"""Returns information on the available fields. """Returns information on the available fields.
@@ -62,6 +62,8 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
'field_filters', 'field_filters',
'row_count', 'row_count',
'completed_row_count', 'completed_row_count',
'completed_row_count_history',
'row_count_history',
] ]
read_only_fields = ['pk', 'user', 'status', 'columns'] read_only_fields = ['pk', 'user', 'status', 'columns']
@@ -220,6 +222,8 @@ class DataImportAcceptRowSerializer(serializers.Serializer):
row.validate(commit=True, request=request) row.validate(commit=True, request=request)
if session := self.context.get('session', None): if session := self.context.get('session', None):
# ensure current state is available
session.refresh_from_db()
session.check_complete() session.check_complete()
return rows return rows
+15 -10
View File
@@ -590,6 +590,21 @@ class Order(
"""Return the Address associated with this order.""" """Return the Address associated with this order."""
return self.address or self.company.primary_address return self.address or self.company.primary_address
@property
def status_text(self):
"""Return the text representation of the current status. This will consider any custom status."""
if self.get_custom_status() is not None:
from generic.states.custom import (
get_logical_value as get_custom_state_logical_value,
)
custom_status = get_custom_state_logical_value(
self.get_custom_status(), model=self._meta.model_name
)
return custom_status.label
else:
return self.status_class.label(self.get_status())
@classmethod @classmethod
def get_status_class(cls): def get_status_class(cls):
"""Return the enumeration class which represents the 'status' field for this model.""" """Return the enumeration class which represents the 'status' field for this model."""
@@ -692,11 +707,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
help_text=_('Purchase order status'), help_text=_('Purchase order status'),
) )
@property
def status_text(self):
"""Return the text representation of the status field."""
return PurchaseOrderStatus.text(self.status)
supplier = models.ForeignKey( supplier = models.ForeignKey(
Company, Company,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -1443,11 +1453,6 @@ class SalesOrder(TotalPriceMixin, Order):
help_text=_('Sales order status'), help_text=_('Sales order status'),
) )
@property
def status_text(self) -> str:
"""Return the text representation of the status field."""
return SalesOrderStatus.text(self.status)
customer_reference = models.CharField( customer_reference = models.CharField(
max_length=64, max_length=64,
blank=True, blank=True,
@@ -126,6 +126,14 @@ class AbstractOrderSerializer(
# status field cannot be set directly # status field cannot be set directly
status = serializers.IntegerField(read_only=True, label=_('Order Status')) status = serializers.IntegerField(read_only=True, label=_('Order Status'))
# can be set directly, but must be valid for the current order status
status_custom_key = serializers.IntegerField(
label=_('Custom Status Key'),
help_text=_('Update order status to a custom value for this logical value'),
allow_null=True,
default=None,
)
# Reference string is *required* # Reference string is *required*
reference = serializers.CharField(required=True) reference = serializers.CharField(required=True)
@@ -198,6 +206,31 @@ class AbstractOrderSerializer(
self.Meta.model.validate_reference_field(reference) self.Meta.model.validate_reference_field(reference)
return reference return reference
def validate_status_custom_key(self, value):
"""Validate the status_custom_key field.
Ensure the custom status key is valid for the logical order status.
"""
if value is None:
return value
from generic.states.custom import get_logical_value
if not isinstance(value, int):
raise ValidationError(_('Custom status key must be an integer'))
try:
custom_status = get_logical_value(
value, model=self.Meta.model._meta.model_name
)
except:
raise ValidationError(_('Invalid custom status key'))
if custom_status.logical_key is not self.instance.status:
raise ValidationError(_('Invalid custom status key for this order status'))
return value
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Add extra information to the queryset.""" """Add extra information to the queryset."""
+129
View File
@@ -6,6 +6,7 @@ import json
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Optional from typing import Optional
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import connection from django.db import connection
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
@@ -240,6 +241,77 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(data['pk'], 1) self.assertEqual(data['pk'], 1)
self.assertEqual(data['description'], 'Ordering some screws') self.assertEqual(data['description'], 'Ordering some screws')
def test_po_status_custom_key_options(self):
"""Test that status_custom_key is exposed as writable in options."""
self.assignRole('purchase_order.add')
response = self.options(self.LIST_URL, expected_code=200)
post = response.data['actions']['POST']
self.assertIn('status_custom_key', post)
self.assertEqual(post['status_custom_key']['required'], False)
self.assertEqual(post['status_custom_key']['read_only'], False)
def test_po_status_custom_key_patch_valid(self):
"""Test patching a valid custom status key for the current PO status."""
self.assignRole('purchase_order.change')
po = models.PurchaseOrder.objects.get(pk=1)
self.assertEqual(po.status, PurchaseOrderStatus.PENDING.value)
custom_status = InvenTreeCustomUserStateModel.objects.create(
key=901,
name='PO Pending Custom',
label='PO Pending Custom',
color='secondary',
logical_key=PurchaseOrderStatus.PENDING.value,
model=ContentType.objects.get_for_model(models.PurchaseOrder),
reference_status='PurchaseOrderStatus',
)
url = reverse('api-po-detail', kwargs={'pk': po.pk})
response = self.patch(
url, {'status_custom_key': custom_status.key}, expected_code=200
)
self.assertEqual(response.data['status'], PurchaseOrderStatus.PENDING.value)
self.assertEqual(response.data['status_custom_key'], custom_status.key)
def test_po_status_custom_key_patch_invalid(self):
"""Test patching an invalid custom status key for a PO."""
self.assignRole('purchase_order.change')
po = models.PurchaseOrder.objects.get(pk=1)
url = reverse('api-po-detail', kwargs={'pk': po.pk})
response = self.patch(url, {'status_custom_key': 999999}, expected_code=400)
self.assertIn('status_custom_key', response.data)
def test_po_status_custom_key_patch_wrong_logical_status(self):
"""Test patching a custom key mapped to a different logical status."""
self.assignRole('purchase_order.change')
po = models.PurchaseOrder.objects.get(pk=1)
self.assertEqual(po.status, PurchaseOrderStatus.PENDING.value)
custom_status = InvenTreeCustomUserStateModel.objects.create(
key=902,
name='PO Placed Custom',
label='PO Placed Custom',
color='secondary',
logical_key=PurchaseOrderStatus.PLACED.value,
model=ContentType.objects.get_for_model(models.PurchaseOrder),
reference_status='PurchaseOrderStatus',
)
url = reverse('api-po-detail', kwargs={'pk': po.pk})
response = self.patch(
url, {'status_custom_key': custom_status.key}, expected_code=400
)
self.assertIn('status_custom_key', response.data)
def test_po_reference(self): def test_po_reference(self):
"""Test that a reference with a too big / small reference is handled correctly.""" """Test that a reference with a too big / small reference is handled correctly."""
# get permissions # get permissions
@@ -1950,6 +2022,63 @@ class SalesOrderTest(OrderTest):
reverse('api-so-detail', kwargs={'pk': 1}), ['customer_detail'] reverse('api-so-detail', kwargs={'pk': 1}), ['customer_detail']
) )
def test_so_custom_status_query_count(self):
"""Test that listing SalesOrders with custom statuses does not cause N+1 queries.
Ensures that resolving the 'status_text' field for custom status values
is O(1) in database queries, not O(N) relative to the number of results.
"""
so_content_type = ContentType.objects.get_for_model(models.SalesOrder)
logical_keys = [
SalesOrderStatus.PENDING.value,
SalesOrderStatus.IN_PROGRESS.value,
SalesOrderStatus.SHIPPED.value,
SalesOrderStatus.ON_HOLD.value,
SalesOrderStatus.COMPLETE.value,
SalesOrderStatus.CANCELLED.value,
SalesOrderStatus.PENDING.value,
SalesOrderStatus.IN_PROGRESS.value,
SalesOrderStatus.SHIPPED.value,
SalesOrderStatus.ON_HOLD.value,
]
custom_statuses = [
InvenTreeCustomUserStateModel.objects.create(
key=2000 + i,
name=f'SoCustomStatus{i}',
label=f'SO Custom Status Label {i}',
color='secondary',
logical_key=logical_keys[i],
model=so_content_type,
reference_status='SalesOrderStatus',
)
for i in range(10)
]
customer = Company.objects.filter(is_customer=True).first()
models.SalesOrder.objects.bulk_create([
models.SalesOrder(
customer=customer,
reference=f'SO-QTEST-{i}',
status=custom_statuses[i % 10].logical_key,
status_custom_key=custom_statuses[i % 10].key,
)
for i in range(100)
])
for limit in [1, 5, 10, 25, 50, 100]:
response = self.get(
self.LIST_URL,
data={'limit': limit},
expected_code=200,
max_query_count=50,
)
for result in response.data['results']:
self.assertIn('status_text', result)
self.assertIsNotNone(result['status_text'])
class SalesOrderLineItemTest(OrderTest): class SalesOrderLineItemTest(OrderTest):
"""Tests for the SalesOrderLineItem API.""" """Tests for the SalesOrderLineItem API."""
+1 -1
View File
@@ -783,7 +783,7 @@ export default function BuildDetail() {
? [] ? []
: [ : [
<StatusRenderer <StatusRenderer
status={build.status_custom_key} status={build.status_custom_key || build.status}
type={ModelType.build} type={ModelType.build}
options={{ size: 'lg' }} options={{ size: 'lg' }}
/>, />,
@@ -543,7 +543,7 @@ export default function PurchaseOrderDetail() {
? [] ? []
: [ : [
<StatusRenderer <StatusRenderer
status={order.status_custom_key} status={order.status_custom_key || order.status}
type={ModelType.purchaseorder} type={ModelType.purchaseorder}
options={{ size: 'lg' }} options={{ size: 'lg' }}
/> />
@@ -390,7 +390,7 @@ export default function ReturnOrderDetail() {
? [] ? []
: [ : [
<StatusRenderer <StatusRenderer
status={order.status_custom_key} status={order.status_custom_key || order.status}
type={ModelType.returnorder} type={ModelType.returnorder}
options={{ size: 'lg' }} options={{ size: 'lg' }}
/> />
@@ -605,7 +605,7 @@ export default function SalesOrderDetail() {
? [] ? []
: [ : [
<StatusRenderer <StatusRenderer
status={order.status_custom_key} status={order.status_custom_key || order.status}
type={ModelType.salesorder} type={ModelType.salesorder}
options={{ size: 'lg' }} options={{ size: 'lg' }}
key={order.pk} key={order.pk}
@@ -356,7 +356,7 @@ export default function TransferOrderDetail() {
? [] ? []
: [ : [
<StatusRenderer <StatusRenderer
status={order.status_custom_key} status={order.status_custom_key || order.status}
type={ModelType.transferorder} type={ModelType.transferorder}
options={{ size: 'lg' }} options={{ size: 'lg' }}
/> />
@@ -18,6 +18,7 @@ import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal useDeleteApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { useImporterState } from '../../states/ImporterState'; import { useImporterState } from '../../states/ImporterState';
import { DateColumn, StatusColumn } from '../ColumnRenderers'; import { DateColumn, StatusColumn } from '../ColumnRenderers';
import { StatusFilterOptions, UserFilter } from '../Filter'; import { StatusFilterOptions, UserFilter } from '../Filter';
@@ -26,6 +27,9 @@ import { InvenTreeTable } from '../InvenTreeTable';
export default function ImportSessionTable() { export default function ImportSessionTable() {
const table = useTable('importsession'); const table = useTable('importsession');
const openImporter = useImporterState((state) => state.openImporter); const openImporter = useImporterState((state) => state.openImporter);
const importSessionStatus = useStatusCodes({
modelType: ModelType.importsession
});
const [selectedSession, setSelectedSession] = useState<number | undefined>( const [selectedSession, setSelectedSession] = useState<number | undefined>(
undefined undefined
@@ -82,7 +86,14 @@ export default function ImportSessionTable() {
sortable: false, sortable: false,
accessor: 'row_count', accessor: 'row_count',
title: t`Imported Rows`, title: t`Imported Rows`,
render: (record: any) => ( render: (record: any) =>
record.status == importSessionStatus.COMPLETE ? (
<ProgressBar
progressLabel={true}
value={record.completed_row_count_history}
maximum={record.row_count_history}
/>
) : (
<ProgressBar <ProgressBar
progressLabel={true} progressLabel={true}
value={record.completed_row_count} value={record.completed_row_count}
+1 -1
View File
@@ -60,7 +60,7 @@ test('Importing - Admin Center', async ({ browser }) => {
await page.getByRole('button', { name: 'Close' }).click(); await page.getByRole('button', { name: 'Close' }).click();
// Confirmation of full import success // Confirmation of full import success
// await page.getByRole('cell', { name: '3 / 3' }).first().waitFor(); await page.getByRole('cell', { name: '3 / 3' }).first().waitFor();
// Manually delete records // Manually delete records
await page.getByRole('checkbox', { name: 'Select all records' }).check(); await page.getByRole('checkbox', { name: 'Select all records' }).check();