2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-02-12 17:27:02 +00:00

[enhancement] Stocktake updates (#11257)

* Allow part queryset to be passed to 'perform_stocktake' function

* Add new options to perform_stocktake

* Allow download of part stocktake snapshot data

* API endpoint for generating a stocktake entry

* Simplify code

* Generate report output

* Dashboard stocktake widget

- Generate stocktake snapshot from the dashboard

* Force stocktake entry for part

* Add docs

* Cleanup docs

* Update API version

* Improve efficiency of stocktake generation

* Error handling

* Add simple playwright test

* Fix typing
This commit is contained in:
Oliver
2026-02-06 10:21:30 +11:00
committed by GitHub
parent 2c59c165ba
commit b4eeba5e31
14 changed files with 545 additions and 68 deletions

View File

@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 447
INVENTREE_API_VERSION = 448
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v448 -> 2026-02-05 : https://github.com/inventree/InvenTree/pull/11257
- Adds API endpoint for manually generating a stocktake entry
v447 -> 2026-02-02 : https://github.com/inventree/InvenTree/pull/11242
- Adds "sub_part_active" filter to BomItem API endpoint

View File

@@ -304,7 +304,7 @@ class DataExportViewMixin:
"""Export the data in the specified format.
Arguments:
export_plugin: The plugin instance to use for exporting the data
export_plugin: The plugin instance to use for exporting the data. If not provided, the default exporter is used
export_format: The file format to export the data in
export_context: Additional context data to pass to the plugin
output: The DataOutput object to write to
@@ -312,6 +312,11 @@ class DataExportViewMixin:
- By default, uses the provided serializer to generate the data, and return it as a file download.
- If a plugin is specified, the plugin can be used to augment or replace the export functionality.
"""
if export_plugin is None:
from plugin.registry import registry
export_plugin = registry.get_plugin('inventree-exporter')
# Get the base serializer class for the view
serializer_class = self.get_serializer_class()

View File

@@ -1204,11 +1204,18 @@ class PartStocktakeFilter(FilterSet):
fields = ['part']
class PartStocktakeList(BulkDeleteMixin, ListCreateAPI):
class PartStocktakeMixin:
"""Mixin class for PartStocktake API endpoints."""
queryset = PartStocktake.objects.all().prefetch_related('part')
serializer_class = part_serializers.PartStocktakeSerializer
class PartStocktakeList(
PartStocktakeMixin, DataExportViewMixin, BulkDeleteMixin, ListCreateAPI
):
"""API endpoint for listing part stocktake information."""
queryset = PartStocktake.objects.all()
serializer_class = part_serializers.PartStocktakeSerializer
filterset_class = PartStocktakeFilter
def get_serializer_context(self):
@@ -1226,14 +1233,65 @@ class PartStocktakeList(BulkDeleteMixin, ListCreateAPI):
ordering = '-pk'
class PartStocktakeDetail(RetrieveUpdateDestroyAPI):
class PartStocktakeDetail(PartStocktakeMixin, RetrieveUpdateDestroyAPI):
"""Detail API endpoint for a single PartStocktake instance.
Note: Only staff (admin) users can access this endpoint.
"""
class PartStocktakeGenerate(CreateAPI):
"""API endpoint for generating a PartStocktake instance."""
queryset = PartStocktake.objects.all()
serializer_class = part_serializers.PartStocktakeSerializer
serializer_class = part_serializers.PartStocktakeGenerateSerializer
def post(self, request, *args, **kwargs):
"""Perform stocktake generation on POST request."""
from common.models import DataOutput
from part.stocktake import perform_stocktake
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
part = data.get('part', None)
category = data.get('category', None)
location = data.get('location', None)
# Do we want to generate a report?
if data.get('generate_report', True):
report_output = DataOutput.objects.create(
user=request.user, output_type='stocktake'
)
else:
report_output = None
# Offload the actual stocktake generation to a background task, as it may take some time to complete
offload_task(
perform_stocktake,
part_id=part.pk if part else None,
category_id=category.pk if category else None,
location_id=location.pk if location else None,
generate_entry=data.get('generate_entry', True),
report_output_id=report_output.pk if report_output else None,
group='stocktake',
)
if report_output:
report_output.refresh_from_db()
result = {
'category': category,
'location': location,
'part': part,
'output': report_output,
}
output_serializer = part_serializers.PartStocktakeGenerateSerializer(result)
return Response(output_serializer.data)
class BomFilter(FilterSet):
@@ -1604,6 +1662,11 @@ part_api_urls = [
PartStocktakeDetail.as_view(),
name='api-part-stocktake-detail',
),
path(
'generate/',
PartStocktakeGenerate.as_view(),
name='api-part-stocktake-generate',
),
path('', PartStocktakeList.as_view(), name='api-part-stocktake-list'),
]),
),

View File

@@ -30,6 +30,7 @@ import part.filters as part_filters
import part.helpers as part_helpers
import stock.models
import users.models
from data_exporter.mixins import DataExportSerializerMixin
from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema
@@ -1187,7 +1188,11 @@ class PartRequirementsSerializer(InvenTree.serializers.InvenTreeModelSerializer)
return part.sales_order_allocation_count(include_variants=True, pending=True)
class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
class PartStocktakeSerializer(
InvenTree.serializers.FilterableSerializerMixin,
DataExportSerializerMixin,
InvenTree.serializers.InvenTreeModelSerializer,
):
"""Serializer for the PartStocktake model."""
class Meta:
@@ -1196,18 +1201,32 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
model = PartStocktake
fields = [
'pk',
'date',
'part',
'part_name',
'part_ipn',
'part_description',
'date',
'item_count',
'quantity',
'cost_min',
'cost_min_currency',
'cost_max',
'cost_max_currency',
# Optional detail fields
'part_detail',
]
read_only_fields = ['date', 'user']
def __init__(self, *args, **kwargs):
"""Custom initialization for PartStocktakeSerializer."""
exclude_pk = kwargs.pop('exclude_pk', False)
super().__init__(*args, **kwargs)
if exclude_pk:
self.fields.pop('pk', None)
quantity = serializers.FloatField()
cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
@@ -1216,6 +1235,23 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
cost_max_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
part_name = serializers.CharField(
source='part.name', read_only=True, label=_('Part Name')
)
part_ipn = serializers.CharField(
source='part.IPN', read_only=True, label=_('Part IPN')
)
part_description = serializers.CharField(
source='part.description', read_only=True, label=_('Part Description')
)
part_detail = enable_filter(
PartBriefSerializer(source='part', read_only=True, many=False, pricing=False),
default_include=False,
)
def save(self):
"""Called when this serializer is saved."""
data = self.validated_data
@@ -1226,6 +1262,72 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
return super().save()
class PartStocktakeGenerateSerializer(serializers.Serializer):
"""Serializer for generating PartStocktake entries."""
class Meta:
"""Metaclass options."""
fields = [
'part',
'category',
'location',
'generate_entry',
'generate_report',
'output',
]
part = serializers.PrimaryKeyRelatedField(
queryset=Part.objects.all(),
label=_('Part'),
help_text=_(
'Select a part to generate stocktake information for that part (and any variant parts)'
),
required=False,
allow_null=True,
)
category = serializers.PrimaryKeyRelatedField(
queryset=PartCategory.objects.all(),
label=_('Category'),
help_text=_(
'Select a category to include all parts within that category (and subcategories)'
),
required=False,
allow_null=True,
)
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
label=_('Location'),
help_text=_(
'Select a location to include all parts with stock in that location (including sub-locations)'
),
required=False,
allow_null=True,
)
generate_entry = serializers.BooleanField(
label=_('Generate Stocktake Entries'),
help_text=_('Save stocktake entries for the selected parts'),
write_only=True,
required=False,
default=False,
)
generate_report = serializers.BooleanField(
label=_('Generate Report'),
help_text=_('Generate a stocktake report for the selected parts'),
write_only=True,
required=False,
default=False,
)
output = common.serializers.DataOutputSerializer(
read_only=True, many=False, label=_('Output')
)
@extend_schema_field(
serializers.CharField(
help_text=_('Select currency from available options')

View File

@@ -1,16 +1,48 @@
"""Stock history functionality."""
from typing import Optional
from django.core.files.base import ContentFile
import structlog
import tablib
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
import common.models
from InvenTree.helpers import current_date
logger = structlog.get_logger('inventree')
def perform_stocktake() -> None:
"""Generate stock history entries for all active parts."""
def perform_stocktake(
part_id: Optional[int] = None,
category_id: Optional[int] = None,
location_id: Optional[int] = None,
exclude_external: Optional[bool] = None,
generate_entry: bool = True,
report_output_id: Optional[int] = None,
) -> None:
"""Capture a snapshot of stock-on-hand and stock value.
Arguments:
part_id: Optional ID of a part to perform stocktake on. If not provided, all active parts will be processed.
category_id: Optional category ID to use to filter parts
location_id: Optional location ID to use to filter stock items
exclude_external: If True, exclude external stock items from the stocktake
generate_entry: If True, create stocktake entries in the database
report_output_id: Optional ID of a DataOutput object for the stocktake report (e.g. for download)
The default implementation creates stocktake entries for all active parts,
and writes these stocktake entries to the database.
Alternatively, the scope of the stocktake can be limited by providing a queryset of parts,
or by providing a category ID or location ID to filter the parts/stock items.
"""
import InvenTree.helpers
import part.models as part_models
import part.serializers as part_serializers
import stock.models as stock_models
from common.currency import currency_code_default
from common.settings import get_global_setting
@@ -18,36 +50,98 @@ def perform_stocktake() -> None:
logger.info('Stocktake functionality is disabled - skipping')
return
exclude_external = get_global_setting(
'STOCKTAKE_EXCLUDE_EXTERNAL', False, cache=False
)
# If exclude_external is not provided, use global setting
if exclude_external is None:
exclude_external = get_global_setting(
'STOCKTAKE_EXCLUDE_EXTERNAL', False, cache=False
)
active_parts = part_models.Part.objects.filter(active=True)
# If a single part is provided, limit to that part
# Otherwise, start with all active parts
if part_id is not None:
try:
part = part_models.Part.objects.get(id=part_id)
parts = part.get_descendants(include_self=True)
except (ValueError, part_models.Part.DoesNotExist):
parts = part_models.Part.objects.all()
else:
parts = part_models.Part.objects.all()
# Only use active parts
parts = parts.filter(active=True)
# Prefetch related pricing information
parts = parts.prefetch_related('pricing_data', 'stock_items')
# Filter part queryset by category, if provided
if category_id is not None:
# Filter parts by category (including subcategories)
try:
category = part_models.PartCategory.objects.get(id=category_id)
parts = parts.filter(
category__in=category.get_descendants(include_self=True)
)
except (ValueError, part_models.PartCategory.DoesNotExist):
pass
# Fetch location if provided
if location_id is not None:
try:
location = stock_models.StockLocation.objects.get(id=location_id)
except (ValueError, stock_models.StockLocation.DoesNotExist):
location = None
else:
location = None
if location is not None:
# Location limited, so we will disable saving of stocktake entries
generate_entry = False
# New history entries to be created
history_entries = []
N_BULK_CREATE = 250
base_currency = currency_code_default()
today = InvenTree.helpers.current_date()
logger.info(
'Creating new stock history entries for %s active parts', active_parts.count()
)
logger.info('Creating new stock history entries for %s parts', parts.count())
for part in active_parts:
# Fetch report output object if provided
if report_output_id is not None:
try:
report_output = common.models.DataOutput.objects.get(id=report_output_id)
except (ValueError, common.models.DataOutput.DoesNotExist):
report_output = None
else:
report_output = None
if report_output:
# Initialize progress on the report output
report_output.total = parts.count()
report_output.progress = 0
report_output.complete = False
report_output.save()
for part in parts:
# Is there a recent stock history record for this part?
if part_models.PartStocktake.objects.filter(
part=part, date__gte=today
).exists():
if (
generate_entry
and part_models.PartStocktake.objects.filter(
part=part, date__gte=today
).exists()
):
continue
pricing = part.pricing
try:
pricing = part.pricing_data
except Exception:
pricing = None
# Fetch all 'in stock' items for this part
stock_items = part.stock_entries(
in_stock=True, include_external=not exclude_external, include_variants=True
location=location,
in_stock=True,
include_external=not exclude_external,
include_variants=True,
)
total_cost_min = Money(0, base_currency)
@@ -56,23 +150,34 @@ def perform_stocktake() -> None:
total_quantity = 0
items_count = 0
if stock_items.count() == 0:
# No stock items - skip this part if location is specified
if location:
continue
for item in stock_items:
# Extract cost information
entry_cost_min = pricing.overall_min or pricing.overall_max
entry_cost_max = pricing.overall_max or pricing.overall_min
entry_cost_min = (
(pricing.overall_min or pricing.overall_max) if pricing else None
)
entry_cost_max = (
(pricing.overall_max or pricing.overall_min) if pricing else None
)
if item.purchase_price is not None:
entry_cost_min = item.purchase_price
entry_cost_max = item.purchase_price
try:
entry_cost_min = (
convert_money(entry_cost_min, base_currency) * item.quantity
)
entry_cost_max = (
convert_money(entry_cost_max, base_currency) * item.quantity
)
entry_cost_min = entry_cost_min * item.quantity
entry_cost_max = entry_cost_max * item.quantity
if entry_cost_min.currency != base_currency:
entry_cost_min = convert_money(entry_cost_min, base_currency)
if entry_cost_max.currency != base_currency:
entry_cost_max = convert_money(entry_cost_max, base_currency)
except Exception:
entry_cost_min = Money(0, base_currency)
entry_cost_max = Money(0, base_currency)
@@ -83,6 +188,13 @@ def perform_stocktake() -> None:
total_cost_min += entry_cost_min
total_cost_max += entry_cost_max
if report_output:
report_output.progress += 1
# Update report progress every few items, to avoid excessive database writes
if report_output.progress % 50 == 0:
report_output.save()
# Add a new stocktake entry for this part
history_entries.append(
part_models.PartStocktake(
@@ -94,11 +206,31 @@ def perform_stocktake() -> None:
)
)
# Batch create stock history entries
if len(history_entries) >= N_BULK_CREATE:
part_models.PartStocktake.objects.bulk_create(history_entries)
history_entries = []
if len(history_entries) > 0:
# Save any remaining stocktake entries
if generate_entry:
# Bulk-create PartStocktake entries
part_models.PartStocktake.objects.bulk_create(history_entries)
if report_output:
# Save report data, and mark as complete
today = current_date()
serializer = part_serializers.PartStocktakeSerializer(exclude_pk=True)
headers = serializer.generate_headers()
# Export the data to a file
dataset = tablib.Dataset(headers=list(headers.values()))
header_keys = list(headers.keys())
for entry in history_entries:
entry.date = today
row = serializer.to_representation(entry)
dataset.append([row.get(header, '') for header in header_keys])
datafile = dataset.export('csv')
report_output.mark_complete(
output=ContentFile(datafile, 'stocktake_report.csv')
)

View File

@@ -120,6 +120,7 @@ export enum ApiEndpoints {
part_pricing_internal = 'part/internal-price/',
part_pricing_sale = 'part/sale-price/',
part_stocktake_list = 'part/stocktake/',
part_stocktake_generate = 'part/stocktake/generate/',
category_list = 'part/category/',
category_tree = 'part/category/tree/',
category_parameter_list = 'part/category/parameters/',

View File

@@ -9,12 +9,13 @@ import GetStartedWidget from './widgets/GetStartedWidget';
import LanguageSelectDashboardWidget from './widgets/LanguageSelectWidget';
import NewsWidget from './widgets/NewsWidget';
import QueryCountDashboardWidget from './widgets/QueryCountDashboardWidget';
import StocktakeDashboardWidget from './widgets/StocktakeDashboardWidget';
/**
*
* @returns A list of built-in dashboard widgets which display the number of results for a particular query
*/
export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
const user = useUserState.getState();
const globalSettings = useGlobalSettingsState.getState();
@@ -186,7 +187,7 @@ export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
});
}
export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
return [
{
label: 'gstart',
@@ -207,10 +208,14 @@ export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
];
}
export function BuiltinSettingsWidgets(): DashboardWidgetProps[] {
function BuiltinSettingsWidgets(): DashboardWidgetProps[] {
return [ColorToggleDashboardWidget(), LanguageSelectDashboardWidget()];
}
function BuiltinActionWidgets(): DashboardWidgetProps[] {
return [StocktakeDashboardWidget()];
}
/**
*
* @returns A list of built-in dashboard widgets
@@ -219,6 +224,7 @@ export default function DashboardWidgetLibrary(): DashboardWidgetProps[] {
return [
...BuiltinQueryCountWidgets(),
...BuiltinGettingStartedWidgets(),
...BuiltinSettingsWidgets()
...BuiltinSettingsWidgets(),
...BuiltinActionWidgets()
];
}

View File

@@ -0,0 +1,71 @@
import { ApiEndpoints, UserRoles, apiUrl } from '@lib/index';
import { t } from '@lingui/core/macro';
import { Button, Stack } from '@mantine/core';
import { IconClipboardList } from '@tabler/icons-react';
import { useState } from 'react';
import useDataOutput from '../../../hooks/UseDataOutput';
import { useCreateApiFormModal } from '../../../hooks/UseForm';
import { useUserState } from '../../../states/UserState';
import type { DashboardWidgetProps } from '../DashboardWidget';
function StocktakeWidget() {
const [outputId, setOutputId] = useState<number | undefined>(undefined);
useDataOutput({
title: t`Generating Stocktake Report`,
id: outputId
});
const stocktakeForm = useCreateApiFormModal({
title: t`Generate Stocktake Report`,
url: apiUrl(ApiEndpoints.part_stocktake_generate),
fields: {
part: {
filters: {
active: true
}
},
category: {},
location: {},
generate_entry: {
value: false
},
generate_report: {
value: true
}
},
submitText: t`Generate`,
successMessage: null,
onFormSuccess: (response) => {
if (response.output?.pk) {
setOutputId(response.output.pk);
}
}
});
return (
<>
{stocktakeForm.modal}
<Stack gap='xs'>
<Button
leftSection={<IconClipboardList />}
onClick={() => stocktakeForm.open()}
>{t`Generate Stocktake Report`}</Button>
</Stack>
</>
);
}
export default function StocktakeDashboardWidget(): DashboardWidgetProps {
const user = useUserState();
return {
label: 'stk',
title: t`Stocktake`,
description: t`Generate a new stocktake report`,
minHeight: 1,
minWidth: 2,
render: () => <StocktakeWidget />,
enabled: user.hasAddRole(UserRoles.part)
};
}

View File

@@ -266,7 +266,6 @@ export function partStocktakeFields(): ApiFormFieldSet {
cost_min: {},
cost_min_currency: {},
cost_max: {},
cost_max_currency: {},
note: {}
cost_max_currency: {}
};
}

View File

@@ -2,6 +2,7 @@ import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { AddItemButton } from '@lib/index';
import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { type ChartTooltipProps, LineChart } from '@mantine/charts';
@@ -18,12 +19,13 @@ import { useCallback, useMemo, useState } from 'react';
import { formatDate, formatPriceRange } from '../../defaults/formatters';
import { partStocktakeFields } from '../../forms/PartForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import { DecimalColumn } from '../../tables/ColumnRenderers';
import { DateColumn, DecimalColumn } from '../../tables/ColumnRenderers';
import { InvenTreeTable } from '../../tables/InvenTreeTable';
/*
@@ -89,19 +91,38 @@ export default function PartStockHistoryDetail({
table: table
});
const newStocktakeEntry = useCreateApiFormModal({
title: t`Generate Stocktake Report`,
url: apiUrl(ApiEndpoints.part_stocktake_generate),
fields: {
part: {
value: partId,
disabled: true
},
generate_entry: {
value: true,
hidden: true
}
},
submitText: t`Generate`,
successMessage: t`Stocktake report scheduled for generation`
});
const tableColumns: TableColumn[] = useMemo(() => {
return [
DecimalColumn({
accessor: 'quantity',
sortable: false,
switchable: false
}),
DateColumn({}),
DecimalColumn({
accessor: 'item_count',
title: t`Stock Items`,
switchable: true,
sortable: false
}),
DecimalColumn({
accessor: 'quantity',
title: t`Stock Quantity`,
sortable: false,
switchable: false
}),
{
accessor: 'cost',
title: t`Stock Value`,
@@ -111,11 +132,6 @@ export default function PartStockHistoryDetail({
currency: record.cost_min_currency
});
}
},
{
accessor: 'date',
sortable: true,
switchable: false
}
];
}, []);
@@ -124,7 +140,7 @@ export default function PartStockHistoryDetail({
(record: any) => {
return [
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.part),
hidden: !user.hasChangeRole(UserRoles.part) || !user.isSuperuser(),
onClick: () => {
setSelectedStocktake(record.pk);
editStocktakeEntry.open();
@@ -176,8 +192,20 @@ export default function PartStockHistoryDetail({
return [min_date.valueOf(), max_date.valueOf()];
}, [chartData]);
const tableActions = useMemo(() => {
return [
<AddItemButton
hidden={!user.hasAddRole(UserRoles.part)}
key='add-stocktake'
tooltip={t`Generate Stocktake Entry`}
onClick={() => newStocktakeEntry.open()}
/>
];
}, [user]);
return (
<>
{newStocktakeEntry.modal}
{editStocktakeEntry.modal}
{deleteStocktakeEntry.modal}
<SimpleGrid cols={{ base: 1, md: 2 }}>
@@ -188,11 +216,13 @@ export default function PartStockHistoryDetail({
props={{
enableSelection: true,
enableBulkDelete: true,
enableDownload: true,
params: {
part: partId,
ordering: '-date'
},
rowActions: rowActions
rowActions: rowActions,
tableActions: tableActions
}}
/>
{table.isLoading ? (

View File

@@ -1,13 +1,18 @@
import type { Page } from '@playwright/test';
import { test } from '../baseFixtures.js';
import { doCachedLogin } from '../login.js';
import { setPluginState } from '../settings.js';
const resetDashboard = async (page: Page) => {
await page.getByLabel('dashboard-menu').click();
await page.getByRole('menuitem', { name: 'Clear Widgets' }).click();
};
test('Dashboard - Basic', async ({ browser }) => {
const page = await doCachedLogin(browser);
// Reset wizards
await page.getByLabel('dashboard-menu').click();
await page.getByRole('menuitem', { name: 'Clear Widgets' }).click();
// Reset dashboard widgets
await resetDashboard(page);
await page.getByText('Use the menu to add widgets').waitFor();
@@ -39,6 +44,28 @@ test('Dashboard - Basic', async ({ browser }) => {
await page.getByLabel('dashboard-accept-layout').click();
});
test('Dashboard - Stocktake', async ({ browser }) => {
// Trigger a "stocktake" report from the dashboard
const page = await doCachedLogin(browser);
// Reset dashboard widgets
await resetDashboard(page);
await page.getByLabel('dashboard-menu').click();
await page.getByRole('menuitem', { name: 'Add Widget' }).click();
await page.getByLabel('dashboard-widgets-filter-input').fill('stocktake');
await page.getByRole('button', { name: 'add-widget-stk' }).click();
await page.waitForTimeout(100);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Generate Stocktake Report' }).click();
await page.getByText('Select a category to include').waitFor();
await page.getByRole('button', { name: 'Generate', exact: true }).waitFor();
});
test('Dashboard - Plugins', async ({ browser }) => {
// Ensure that the "SampleUI" plugin is enabled
await setPluginState({
@@ -48,6 +75,8 @@ test('Dashboard - Plugins', async ({ browser }) => {
const page = await doCachedLogin(browser);
await resetDashboard(page);
// Add a dashboard widget from the SampleUI plugin
await page.getByLabel('dashboard-menu').click();
await page.getByRole('menuitem', { name: 'Add Widget' }).click();