2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-08-09 21:30:54 +00:00

Merge branch 'master' of https://github.com/inventree/InvenTree into pui-maintine-v7

This commit is contained in:
Matthias Mair
2024-04-17 14:06:39 +02:00
47 changed files with 637 additions and 449 deletions

View File

@@ -1,12 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 187
INVENTREE_API_VERSION = 188
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v187 - 2024-03-10 : https://github.com/inventree/InvenTree/pull/6985
v188 - 2024-04-16 : https://github.com/inventree/InvenTree/pull/6970
- Adds session authentication support for the API
- Improvements for login / logout endpoints for better support of React web interface
v187 - 2024-04-10 : https://github.com/inventree/InvenTree/pull/6985
- Allow Part list endpoint to be sorted by pricing_min and pricing_max values
- Allow BomItem list endpoint to be sorted by pricing_min and pricing_max values
- Allow InternalPrice and SalePrice endpoints to be sorted by quantity

View File

@@ -492,10 +492,18 @@ if DEBUG:
'rest_framework.renderers.BrowsableAPIRenderer'
)
# dj-rest-auth
# JWT switch
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
REST_USE_JWT = USE_JWT
# dj-rest-auth
REST_AUTH = {
'SESSION_LOGIN': True,
'TOKEN_MODEL': 'users.models.ApiToken',
'TOKEN_CREATOR': 'users.models.default_create_token',
'USE_JWT': USE_JWT,
}
OLD_PASSWORD_FIELD_ENABLED = True
REST_AUTH_REGISTER_SERIALIZERS = {
'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'
@@ -510,6 +518,7 @@ if USE_JWT:
)
INSTALLED_APPS.append('rest_framework_simplejwt')
# WSGI default setting
WSGI_APPLICATION = 'InvenTree.wsgi.application'
@@ -1092,6 +1101,13 @@ if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0:
)
sys.exit(-1)
# Additional CSRF settings
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
USE_X_FORWARDED_HOST = get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_HOST',
config_key='use_x_forwarded_host',

View File

@@ -160,6 +160,7 @@ apipatterns = [
SocialAccountDisconnectView.as_view(),
name='social_account_disconnect',
),
path('login/', users.api.Login.as_view(), name='api-login'),
path('logout/', users.api.Logout.as_view(), name='api-logout'),
path(
'login-redirect/',

View File

@@ -8,9 +8,11 @@ from django.contrib.auth.models import Group, User
from django.urls import include, path, re_path
from django.views.generic.base import RedirectView
from dj_rest_auth.views import LogoutView
from dj_rest_auth.views import LoginView, LogoutView
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
from rest_framework import exceptions, permissions
from rest_framework.authentication import BasicAuthentication
from rest_framework.decorators import authentication_classes
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -205,6 +207,18 @@ class GroupList(ListCreateAPI):
ordering_fields = ['name']
@authentication_classes([BasicAuthentication])
@extend_schema_view(
post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged in')}
)
)
class Login(LoginView):
"""API view for logging in via API."""
...
@extend_schema_view(
post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged out')}

View File

@@ -56,6 +56,17 @@ def default_token_expiry():
return InvenTree.helpers.current_date() + datetime.timedelta(days=365)
def default_create_token(token_model, user, serializer):
"""Generate a default value for the token."""
token = token_model.objects.filter(user=user, name='', revoked=False)
if token.exists():
return token.first()
else:
return token_model.objects.create(user=user, name='')
class ApiToken(AuthToken, InvenTree.models.MetadataMixin):
"""Extends the default token model provided by djangorestframework.authtoken.

View File

@@ -134,7 +134,7 @@ googleapis-common-protos==1.63.0
# opentelemetry-exporter-otlp-proto-http
grpcio==1.62.1
# via opentelemetry-exporter-otlp-proto-grpc
gunicorn==21.2.0
gunicorn==22.0.0
html5lib==1.1
# via weasyprint
icalendar==5.0.12

View File

@@ -1,40 +1,24 @@
import { QueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { getCsrfCookie } from './functions/auth';
import { useLocalState } from './states/LocalState';
import { useSessionState } from './states/SessionState';
// Global API instance
export const api = axios.create({});
/*
* Setup default settings for the Axios API instance.
*
* This includes:
* - Base URL
* - Authorization token (if available)
* - CSRF token (if available)
*/
export function setApiDefaults() {
const host = useLocalState.getState().host;
const token = useSessionState.getState().token;
api.defaults.baseURL = host;
api.defaults.timeout = 2500;
api.defaults.headers.common['Authorization'] = token
? `Token ${token}`
: undefined;
if (!!getCsrfCookie()) {
api.defaults.withCredentials = true;
api.defaults.xsrfCookieName = 'csrftoken';
api.defaults.xsrfHeaderName = 'X-CSRFToken';
} else {
api.defaults.withCredentials = false;
api.defaults.xsrfCookieName = undefined;
api.defaults.xsrfHeaderName = undefined;
}
api.defaults.withCredentials = true;
api.defaults.withXSRFToken = true;
api.defaults.xsrfCookieName = 'csrftoken';
api.defaults.xsrfHeaderName = 'X-CSRFToken';
}
export const queryClient = new QueryClient();

View File

@@ -0,0 +1,9 @@
import { formatCurrency } from '../../defaults/formatters';
export function tooltipFormatter(label: any, currency: string) {
return (
formatCurrency(label, {
currency: currency
})?.toString() ?? ''
);
}

View File

@@ -12,16 +12,14 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { doBasicLogin, doSimpleLogin } from '../../functions/auth';
import { doBasicLogin, doSimpleLogin, isLoggedIn } from '../../functions/auth';
import { showLoginNotification } from '../../functions/notifications';
import { apiUrl, useServerApiState } from '../../states/ApiState';
import { useSessionState } from '../../states/SessionState';
import { SsoButton } from '../buttons/SSOButton';
export function AuthenticationForm() {
@@ -46,19 +44,18 @@ export function AuthenticationForm() {
).then(() => {
setIsLoggingIn(false);
if (useSessionState.getState().hasToken()) {
notifications.show({
if (isLoggedIn()) {
showLoginNotification({
title: t`Login successful`,
message: t`Welcome back!`,
color: 'green',
icon: <IconCheck size="1rem" />
message: t`Logged in successfully`
});
navigate(location?.state?.redirectFrom ?? '/home');
} else {
notifications.show({
showLoginNotification({
title: t`Login failed`,
message: t`Check your input and try again.`,
color: 'red'
success: false
});
}
});
@@ -67,18 +64,15 @@ export function AuthenticationForm() {
setIsLoggingIn(false);
if (ret?.status === 'ok') {
notifications.show({
showLoginNotification({
title: t`Mail delivery successful`,
message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.`,
color: 'green',
icon: <IconCheck size="1rem" />,
autoClose: false
message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.`
});
} else {
notifications.show({
title: t`Input error`,
showLoginNotification({
title: t`Mail delivery failed`,
message: t`Check your input and try again.`,
color: 'red'
success: false
});
}
});
@@ -193,11 +187,9 @@ export function RegistrationForm() {
.then((ret) => {
if (ret?.status === 204) {
setIsRegistering(false);
notifications.show({
showLoginNotification({
title: t`Registration successful`,
message: t`Please confirm your email address to complete the registration`,
color: 'green',
icon: <IconCheck size="1rem" />
message: t`Please confirm your email address to complete the registration`
});
navigate('/home');
}
@@ -212,11 +204,10 @@ export function RegistrationForm() {
if (err.response?.data?.non_field_errors) {
err_msg = err.response.data.non_field_errors;
}
notifications.show({
showLoginNotification({
title: t`Input error`,
message: t`Check your input and try again. ` + err_msg,
color: 'red',
autoClose: 30000
success: false
});
}
});

View File

@@ -1,15 +1,12 @@
/**
* Component for loading an image from the InvenTree server,
* using the API's token authentication.
* Component for loading an image from the InvenTree server
*
* Image caching is handled automagically by the browsers cache
*/
import { Image, ImageProps, Skeleton, Stack } from '@mantine/core';
import { useId } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useMemo } from 'react';
import { api } from '../../App';
import { useLocalState } from '../../states/LocalState';
interface ApiImageProps extends ImageProps {
onClick?: (event: any) => void;
@@ -19,61 +16,20 @@ interface ApiImageProps extends ImageProps {
* Construct an image container which will load and display the image
*/
export function ApiImage(props: ApiImageProps) {
const [image, setImage] = useState<string>('');
const { host } = useLocalState.getState();
const [authorized, setAuthorized] = useState<boolean>(true);
const queryKey = useId();
const _imgQuery = useQuery({
queryKey: ['image', queryKey, props.src],
enabled:
authorized &&
props.src != undefined &&
props.src != null &&
props.src != '',
queryFn: async () => {
if (!props.src) {
return null;
}
return api
.get(props.src, {
responseType: 'blob'
})
.then((response) => {
switch (response.status) {
case 200:
let img = new Blob([response.data], {
type: response.headers['content-type']
});
let url = URL.createObjectURL(img);
setImage(url);
break;
default:
// User is not authorized to view this image, or the image is not available
setImage('');
setAuthorized(false);
break;
}
return response;
})
.catch((_error) => {
return null;
});
},
refetchOnMount: true,
refetchOnWindowFocus: false
});
const imageUrl = useMemo(() => {
return `${host}${props.src}`;
}, [host, props.src]);
return (
<Stack>
{image && image.length > 0 ? (
{imageUrl ? (
<Image
{...props}
src={image}
//fallbackSrc="https://placehold.co/600x400?text=Placeholder"
src={imageUrl}
fit="contain"
//fallbackSrc="https://placehold.co/600x400?text=Placeholder"
/>
) : (
<Skeleton h={props?.h ?? props.w} w={props?.w ?? props.h} />

View File

@@ -9,7 +9,9 @@ import {
IconLink,
IconPhoto
} from '@tabler/icons-react';
import { ReactNode } from 'react';
import { ReactNode, useMemo } from 'react';
import { useLocalState } from '../../states/LocalState';
/**
* Return an icon based on the provided filename
@@ -59,10 +61,20 @@ export function AttachmentLink({
}): ReactNode {
let text = external ? attachment : attachment.split('/').pop();
const host = useLocalState((s) => s.host);
const url = useMemo(() => {
if (external) {
return attachment;
}
return `${host}${attachment}`;
}, [host, attachment, external]);
return (
<Group justify="left" gap="sm">
{external ? <IconLink /> : attachmentIcon(attachment)}
<Anchor href={attachment} target="_blank" rel="noopener noreferrer">
<Anchor href={url} target="_blank" rel="noopener noreferrer">
{text}
</Anchor>
</Group>

View File

@@ -6,17 +6,15 @@ import { useEffect, useState } from 'react';
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { getActions } from '../../defaults/actions';
import { isLoggedIn } from '../../functions/auth';
import * as classes from '../../main.css';
import { useSessionState } from '../../states/SessionState';
import { Footer } from './Footer';
import { Header } from './Header';
export const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
const [token] = useSessionState((state) => [state.token]);
const location = useLocation();
if (!token) {
if (!isLoggedIn()) {
return (
<Navigate to="/logged-in" state={{ redirectFrom: location.pathname }} />
);

View File

@@ -15,14 +15,15 @@ export enum ApiEndpoints {
user_roles = 'user/roles/',
user_token = 'user/token/',
user_simple_login = 'email/generate/',
user_reset = 'auth/password/reset/', // Note leading prefix here
user_reset_set = 'auth/password/reset/confirm/', // Note leading prefix here
user_reset = 'auth/password/reset/',
user_reset_set = 'auth/password/reset/confirm/',
user_sso = 'auth/social/',
user_sso_remove = 'auth/social/:id/disconnect/',
user_emails = 'auth/emails/',
user_email_remove = 'auth/emails/:id/remove/',
user_email_verify = 'auth/emails/:id/verify/',
user_email_primary = 'auth/emails/:id/primary/',
user_login = 'auth/login/',
user_logout = 'auth/logout/',
user_register = 'auth/registration/',

View File

@@ -1,15 +1,13 @@
import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import axios from 'axios';
import { api, setApiDefaults } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState';
import { useLocalState } from '../states/LocalState';
import { useSessionState } from '../states/SessionState';
const tokenName: string = 'inventree-web-app';
import { fetchGlobalStates } from '../states/states';
import { showLoginNotification } from './notifications';
/**
* Attempt to login using username:password combination.
@@ -24,26 +22,35 @@ export const doBasicLogin = async (username: string, password: string) => {
return;
}
// At this stage, we can assume that we are not logged in, and we have no token
useSessionState.getState().clearToken();
clearCsrfCookie();
// Request new token from the server
await axios
.get(apiUrl(ApiEndpoints.user_token), {
auth: { username, password },
baseURL: host,
timeout: 2000,
params: {
name: tokenName
const login_url = apiUrl(ApiEndpoints.user_login);
// Attempt login with
await api
.post(
login_url,
{
username: username,
password: password
},
{
baseURL: host
}
})
)
.then((response) => {
if (response.status == 200 && response.data.token) {
// A valid token has been returned - save, and login
useSessionState.getState().setToken(response.data.token);
switch (response.status) {
case 200:
fetchGlobalStates();
break;
default:
clearCsrfCookie();
break;
}
})
.catch(() => {});
.catch(() => {
clearCsrfCookie();
});
};
/**
@@ -53,27 +60,15 @@ export const doBasicLogin = async (username: string, password: string) => {
*/
export const doLogout = async (navigate: any) => {
// Logout from the server session
await api.post(apiUrl(ApiEndpoints.user_logout)).catch(() => {
// If an error occurs here, we are likely already logged out
await api.post(apiUrl(ApiEndpoints.user_logout)).finally(() => {
clearCsrfCookie();
navigate('/login');
return;
showLoginNotification({
title: t`Logged Out`,
message: t`Successfully logged out`
});
});
// Logout from this session
// Note that clearToken() then calls setApiDefaults()
clearCsrfCookie();
useSessionState.getState().clearToken();
notifications.hide('login');
notifications.show({
id: 'login',
title: t`Logout successful`,
message: t`You have been logged out`,
color: 'green',
icon: <IconCheck size="1rem" />
});
navigate('/login');
};
export const doSimpleLogin = async (email: string) => {
@@ -134,55 +129,33 @@ export function checkLoginState(
) {
setApiDefaults();
if (redirect == '/') {
redirect = '/home';
}
// Callback function when login is successful
const loginSuccess = () => {
notifications.hide('login');
notifications.show({
id: 'login',
showLoginNotification({
title: t`Logged In`,
message: t`Found an existing login - welcome back!`,
color: 'green',
icon: <IconCheck size="1rem" />
message: t`Successfully logged in`
});
navigate(redirect ?? '/home');
};
// Callback function when login fails
const loginFailure = () => {
useSessionState.getState().clearToken();
if (!no_redirect) {
navigate('/login', { state: { redirectFrom: redirect } });
}
};
if (useSessionState.getState().hasToken()) {
// An existing token is available - check if it works
// Check the 'user_me' endpoint to see if the user is logged in
if (isLoggedIn()) {
api
.get(apiUrl(ApiEndpoints.user_me), {
timeout: 2000
})
.then((val) => {
if (val.status === 200) {
// Success: we are logged in (and we already have a token)
loginSuccess();
} else {
loginFailure();
}
})
.catch(() => {
loginFailure();
});
} else if (getCsrfCookie()) {
// Try to fetch a new token using the CSRF cookie
api
.get(apiUrl(ApiEndpoints.user_token), {
params: {
name: tokenName
}
})
.get(apiUrl(ApiEndpoints.user_me))
.then((response) => {
if (response.status == 200 && response.data.token) {
useSessionState.getState().setToken(response.data.token);
if (response.status == 200) {
loginSuccess();
} else {
loginFailure();
@@ -192,7 +165,6 @@ export function checkLoginState(
loginFailure();
});
} else {
// No token, no cookie - redirect to login page
loginFailure();
}
}
@@ -209,8 +181,12 @@ export function getCsrfCookie() {
return cookieValue;
}
export function isLoggedIn() {
return !!getCsrfCookie();
}
/*
* Clear out the CSRF cookie (force session logout)
* Clear out the CSRF and session cookies (force session logout)
*/
export function clearCsrfCookie() {
document.cookie =

View File

@@ -9,6 +9,7 @@ import {
IconBuilding,
IconBuildingFactory2,
IconBuildingStore,
IconBusinessplan,
IconCalendar,
IconCalendarStats,
IconCalendarTime,
@@ -99,6 +100,7 @@ const icons = {
info: IconInfoCircle,
details: IconInfoCircle,
parameters: IconList,
list: IconList,
stock: IconPackages,
variants: IconVersions,
allocations: IconBookmarks,
@@ -170,6 +172,7 @@ const icons = {
customer: IconUser,
quantity: IconNumbers,
progress: IconProgressCheck,
total_cost: IconBusinessplan,
reference: IconHash,
serial: IconHash,
website: IconWorld,

View File

@@ -1,5 +1,6 @@
import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications';
import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react';
/**
* Show a notification that the feature is not yet implemented
@@ -34,3 +35,28 @@ export function invalidResponse(returnCode: number) {
color: 'red'
});
}
/*
* Display a login / logout notification message.
* Any existing login notification(s) will be hidden.
*/
export function showLoginNotification({
title,
message,
success = true
}: {
title: string;
message: string;
success?: boolean;
}) {
notifications.hide('login');
notifications.show({
title: title,
message: message,
color: success ? 'green' : 'red',
icon: success ? <IconCircleCheck /> : <IconExclamationCircle />,
id: 'login',
autoClose: 5000
});
}

View File

@@ -2,7 +2,7 @@ import { Trans, t } from '@lingui/macro';
import { Center, Container, Paper, Text } from '@mantine/core';
import { useDisclosure, useToggle } from '@mantine/hooks';
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { setApiDefaults } from '../../App';
import { AuthFormOptions } from '../../components/forms/AuthFormOptions';
@@ -13,7 +13,7 @@ import {
} from '../../components/forms/AuthenticationForm';
import { InstanceOptions } from '../../components/forms/InstanceOptions';
import { defaultHostKey } from '../../defaults/defaultHostList';
import { checkLoginState } from '../../functions/auth';
import { checkLoginState, doBasicLogin } from '../../functions/auth';
import { useServerApiState } from '../../states/ApiState';
import { useLocalState } from '../../states/LocalState';
@@ -33,6 +33,7 @@ export default function Login() {
const [loginMode, setMode] = useDisclosure(true);
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
// Data manipulation functions
function ChangeHost(newHost: string | null): void {
@@ -49,6 +50,16 @@ export default function Login() {
}
checkLoginState(navigate, location?.state?.redirectFrom, true);
// check if we got login params (login and password)
if (searchParams.has('login') && searchParams.has('password')) {
doBasicLogin(
searchParams.get('login') ?? '',
searchParams.get('password') ?? ''
).then(() => {
navigate(location?.state?.redirectFrom ?? '/home');
});
}
}, []);
// Fetch server data on mount if no server data is present

View File

@@ -0,0 +1,34 @@
import { Trans } from '@lingui/macro';
import { Card, Container, Group, Loader, Stack, Text } from '@mantine/core';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { doLogout } from '../../functions/auth';
/* Expose a route for explicit logout via URL */
export default function Logout() {
const navigate = useNavigate();
useEffect(() => {
doLogout(navigate);
}, []);
return (
<>
<Container>
<Stack align="center">
<Card shadow="sm" padding="lg" radius="md">
<Stack>
<Text size="lg">
<Trans>Logging out</Trans>
</Text>
<Group justify="center">
<Loader />
</Group>
</Stack>
</Card>
</Stack>
</Container>
</>
);
}

View File

@@ -1,6 +1,6 @@
import { t } from '@lingui/macro';
import { Accordion, Alert, LoadingOverlay, Stack, Text } from '@mantine/core';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
@@ -15,6 +15,19 @@ import SaleHistoryPanel from './pricing/SaleHistoryPanel';
import SupplierPricingPanel from './pricing/SupplierPricingPanel';
import VariantPricingPanel from './pricing/VariantPricingPanel';
export enum panelOptions {
overview = 'overview',
purchase = 'purchase',
internal = 'internal',
supplier = 'supplier',
bom = 'bom',
variant = 'variant',
sale_pricing = 'sale-pricing',
sale_history = 'sale-history',
override = 'override',
overall = 'overall'
}
export default function PartPricingPanel({ part }: { part: any }) {
const user = useUserState();
@@ -40,6 +53,17 @@ export default function PartPricingPanel({ part }: { part: any }) {
return user.hasViewRole(UserRoles.sales_order) && part?.salable;
}, [user, part]);
const [value, setValue] = useState<string[]>([panelOptions.overview]);
function doNavigation(panel: panelOptions) {
if (!value.includes(panel)) {
setValue([...value, panel]);
}
const element = document.getElementById(panel);
if (element) {
element.scrollIntoView();
}
}
return (
<Stack gap="xs">
<LoadingOverlay visible={instanceQuery.isLoading} />
@@ -49,18 +73,27 @@ export default function PartPricingPanel({ part }: { part: any }) {
</Alert>
)}
{pricing && (
<Accordion multiple defaultValue={['overview']}>
<Accordion multiple value={value} onChange={setValue}>
<PricingPanel
content={<PricingOverviewPanel part={part} pricing={pricing} />}
label="overview"
content={
<PricingOverviewPanel
part={part}
pricing={pricing}
doNavigation={doNavigation}
/>
}
label={panelOptions.overview}
title={t`Pricing Overview`}
visible={true}
/>
<PricingPanel
content={<PurchaseHistoryPanel part={part} />}
label="purchase"
label={panelOptions.purchase}
title={t`Purchase History`}
visible={purchaseOrderPricing}
disabled={
!pricing?.purchase_cost_min || !pricing?.purchase_cost_max
}
/>
<PricingPanel
content={
@@ -69,27 +102,35 @@ export default function PartPricingPanel({ part }: { part: any }) {
endpoint={ApiEndpoints.part_pricing_internal}
/>
}
label="internal"
label={panelOptions.internal}
title={t`Internal Pricing`}
visible={internalPricing}
disabled={
!pricing?.internal_cost_min || !pricing?.internal_cost_max
}
/>
<PricingPanel
content={<SupplierPricingPanel part={part} />}
label="supplier"
label={panelOptions.supplier}
title={t`Supplier Pricing`}
visible={purchaseOrderPricing}
disabled={
!pricing?.supplier_price_min || !pricing?.supplier_price_max
}
/>
<PricingPanel
content={<BomPricingPanel part={part} pricing={pricing} />}
label="bom"
label={panelOptions.bom}
title={t`BOM Pricing`}
visible={part?.assembly}
disabled={!pricing?.bom_cost_min || !pricing?.bom_cost_max}
/>
<PricingPanel
content={<VariantPricingPanel part={part} pricing={pricing} />}
label="variant"
label={panelOptions.variant}
title={t`Variant Pricing`}
visible={part?.is_template}
disabled={!pricing?.variant_cost_min || !pricing?.variant_cost_max}
/>
<PricingPanel
content={
@@ -98,15 +139,17 @@ export default function PartPricingPanel({ part }: { part: any }) {
endpoint={ApiEndpoints.part_pricing_sale}
/>
}
label="sale-pricing"
label={panelOptions.sale_pricing}
title={t`Sale Pricing`}
visible={salesOrderPricing}
disabled={!pricing?.sale_price_min || !pricing?.sale_price_max}
/>
<PricingPanel
content={<SaleHistoryPanel part={part} />}
label="sale-history"
label={panelOptions.sale_history}
title={t`Sale History`}
visible={salesOrderPricing}
disabled={!pricing?.sale_history_min || !pricing?.sale_history_max}
/>
</Accordion>
)}

View File

@@ -21,7 +21,12 @@ import {
} from 'recharts';
import { CHART_COLORS } from '../../../components/charts/colors';
import { formatDecimal, formatPriceRange } from '../../../defaults/formatters';
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
import {
formatCurrency,
formatDecimal,
formatPriceRange
} from '../../../defaults/formatters';
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
import { ModelType } from '../../../enums/ModelType';
import { useTable } from '../../../hooks/UseTable';
@@ -32,7 +37,7 @@ import { InvenTreeTable } from '../../../tables/InvenTreeTable';
import { NoPricingData } from './PricingPanel';
// Display BOM data as a pie chart
function BomPieChart({ data }: { data: any[] }) {
function BomPieChart({ data, currency }: { data: any[]; currency: string }) {
return (
<ResponsiveContainer width="100%" height={500}>
<PieChart>
@@ -64,20 +69,30 @@ function BomPieChart({ data }: { data: any[] }) {
/>
))}
</Pie>
<Tooltip />
<Tooltip
formatter={(label, payload) => tooltipFormatter(label, currency)}
/>
</PieChart>
</ResponsiveContainer>
);
}
// Display BOM data as a bar chart
function BomBarChart({ data }: { data: any[] }) {
function BomBarChart({ data, currency }: { data: any[]; currency: string }) {
return (
<ResponsiveContainer width="100%" height={500}>
<BarChart data={data}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<YAxis
tickFormatter={(value, index) =>
formatCurrency(value, {
currency: currency
})?.toString() ?? ''
}
/>
<Tooltip
formatter={(label, payload) => tooltipFormatter(label, currency)}
/>
<Legend />
<Bar
dataKey="total_price_min"
@@ -202,8 +217,12 @@ export default function BomPricingPanel({
/>
{bomPricingData.length > 0 ? (
<Stack gap="xs">
{chartType == 'bar' && <BomBarChart data={bomPricingData} />}
{chartType == 'pie' && <BomPieChart data={bomPricingData} />}
{chartType == 'bar' && (
<BomBarChart data={bomPricingData} currency={pricing?.currency} />
)}
{chartType == 'pie' && (
<BomPieChart data={bomPricingData} currency={pricing?.currency} />
)}
<SegmentedControl
value={chartType}
onChange={setChartType}

View File

@@ -13,6 +13,7 @@ import {
import { AddItemButton } from '../../../components/buttons/AddItemButton';
import { CHART_COLORS } from '../../../components/charts/colors';
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
import { ApiFormFieldSet } from '../../../components/forms/fields/ApiFormField';
import { formatCurrency } from '../../../defaults/formatters';
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
@@ -144,6 +145,13 @@ export default function PriceBreakPanel({
[user]
);
const currency: string = useMemo(() => {
if (table.records.length === 0) {
return '';
}
return table.records[0].currency;
}, [table.records]);
return (
<>
{newPriceBreak.modal}
@@ -166,8 +174,18 @@ export default function PriceBreakPanel({
<ResponsiveContainer width="100%" height={500}>
<BarChart data={table.records}>
<XAxis dataKey="quantity" />
<YAxis />
<Tooltip />
<YAxis
tickFormatter={(value, index) =>
formatCurrency(value, {
currency: currency
})?.toString() ?? ''
}
/>
<Tooltip
formatter={(label, payload) =>
tooltipFormatter(label, currency)
}
/>
<Legend />
<Bar
dataKey="price"

View File

@@ -1,5 +1,13 @@
import { t } from '@lingui/macro';
import { Alert, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
import {
Alert,
Anchor,
Group,
Paper,
SimpleGrid,
Stack,
Text
} from '@mantine/core';
import {
IconBuildingWarehouse,
IconChartDonut,
@@ -22,11 +30,13 @@ import {
} from 'recharts';
import { CHART_COLORS } from '../../../components/charts/colors';
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
import { formatCurrency, renderDate } from '../../../defaults/formatters';
import { panelOptions } from '../PartPricingPanel';
interface PricingOverviewEntry {
icon: ReactNode;
name: string;
name: panelOptions;
title: string;
min_value: number | null | undefined;
max_value: number | null | undefined;
@@ -36,10 +46,12 @@ interface PricingOverviewEntry {
export default function PricingOverviewPanel({
part,
pricing
pricing,
doNavigation
}: {
part: any;
pricing: any;
doNavigation: (panel: panelOptions) => void;
}): ReactNode {
const columns: any[] = useMemo(() => {
return [
@@ -47,10 +59,17 @@ export default function PricingOverviewPanel({
accessor: 'title',
title: t`Pricing Category`,
render: (record: PricingOverviewEntry) => {
const is_link = record.name !== panelOptions.overall;
return (
<Group justify="left" gap="xs">
{record.icon}
<Text fw={700}>{record.title}</Text>
{is_link ? (
<Anchor fw={700} onClick={() => doNavigation(record.name)}>
{record.title}
</Anchor>
) : (
<Text fw={700}>{record.title}</Text>
)}
</Group>
);
}
@@ -86,56 +105,70 @@ export default function PricingOverviewPanel({
const overviewData: PricingOverviewEntry[] = useMemo(() => {
return [
{
name: 'internal',
name: panelOptions.internal,
title: t`Internal Pricing`,
icon: <IconList />,
min_value: pricing?.internal_cost_min,
max_value: pricing?.internal_cost_max
},
{
name: 'bom',
name: panelOptions.bom,
title: t`BOM Pricing`,
icon: <IconChartDonut />,
min_value: pricing?.bom_cost_min,
max_value: pricing?.bom_cost_max
},
{
name: 'purchase',
name: panelOptions.purchase,
title: t`Purchase Pricing`,
icon: <IconShoppingCart />,
min_value: pricing?.purchase_cost_min,
max_value: pricing?.purchase_cost_max
},
{
name: 'supplier',
name: panelOptions.supplier,
title: t`Supplier Pricing`,
icon: <IconBuildingWarehouse />,
min_value: pricing?.supplier_price_min,
max_value: pricing?.supplier_price_max
},
{
name: 'variants',
name: panelOptions.variant,
title: t`Variant Pricing`,
icon: <IconTriangleSquareCircle />,
min_value: pricing?.variant_cost_min,
max_value: pricing?.variant_cost_max
},
{
name: 'override',
name: panelOptions.sale_pricing,
title: t`Sale Pricing`,
icon: <IconTriangleSquareCircle />,
min_value: pricing?.sale_price_min,
max_value: pricing?.sale_price_max
},
{
name: panelOptions.sale_history,
title: t`Sale History`,
icon: <IconTriangleSquareCircle />,
min_value: pricing?.sale_history_min,
max_value: pricing?.sale_history_max
},
{
name: panelOptions.override,
title: t`Override Pricing`,
icon: <IconExclamationCircle />,
min_value: pricing?.override_min,
max_value: pricing?.override_max
},
{
name: 'overall',
name: panelOptions.overall,
title: t`Overall Pricing`,
icon: <IconReportAnalytics />,
min_value: pricing?.overall_min,
max_value: pricing?.overall_max
}
].filter((entry) => {
return entry.min_value !== null || entry.max_value !== null;
return !(entry.min_value == null || entry.max_value == null);
});
}, [part, pricing]);
@@ -158,8 +191,18 @@ export default function PricingOverviewPanel({
<ResponsiveContainer width="100%" height={500}>
<BarChart data={overviewData}>
<XAxis dataKey="title" />
<YAxis />
<Tooltip />
<YAxis
tickFormatter={(value, index) =>
formatCurrency(value, {
currency: pricing?.currency
})?.toString() ?? ''
}
/>
<Tooltip
formatter={(label, payload) =>
tooltipFormatter(label, pricing?.currency)
}
/>
<Legend />
<Bar
dataKey="min_value"

View File

@@ -1,28 +1,58 @@
import { t } from '@lingui/macro';
import { Accordion, Alert, Space, Stack, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import {
Accordion,
AccordionControlProps,
Alert,
Box,
Space,
Stack,
Text,
Tooltip
} from '@mantine/core';
import { IconAlertCircle, IconExclamationCircle } from '@tabler/icons-react';
import { ReactNode } from 'react';
import { StylishText } from '../../../components/items/StylishText';
import { panelOptions } from '../PartPricingPanel';
function AccordionControl(props: AccordionControlProps) {
return (
<Box style={{ display: 'flex', alignItems: 'center' }}>
{props.disabled && (
<Tooltip
label={t`No data available`}
children={<IconAlertCircle size="1rem" color="gray" />}
/>
)}
<Accordion.Control
{...props}
pl={props.disabled ? '0.25rem' : '1.25rem'}
/>
</Box>
);
}
export default function PricingPanel({
content,
label,
title,
visible
visible,
disabled = undefined
}: {
content: ReactNode;
label: string;
label: panelOptions;
title: string;
visible: boolean;
disabled?: boolean | undefined;
}): ReactNode {
const is_disabled = disabled === undefined ? false : disabled;
return (
visible && (
<Accordion.Item value={label}>
<Accordion.Control>
<Accordion.Item value={label} id={label}>
<AccordionControl disabled={is_disabled}>
<StylishText size="lg">{title}</StylishText>
</Accordion.Control>
<Accordion.Panel>{content}</Accordion.Panel>
</AccordionControl>
<Accordion.Panel>{!is_disabled && content}</Accordion.Panel>
</Accordion.Item>
)
);

View File

@@ -12,6 +12,7 @@ import {
} from 'recharts';
import { CHART_COLORS } from '../../../components/charts/colors';
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
import { formatCurrency, renderDate } from '../../../defaults/formatters';
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
import { useTable } from '../../../hooks/UseTable';
@@ -95,6 +96,13 @@ export default function PurchaseHistoryPanel({
];
}, []);
const currency: string = useMemo(() => {
if (table.records.length === 0) {
return '';
}
return table.records[0].purchase_price_currency;
}, [table.records]);
const purchaseHistoryData = useMemo(() => {
return table.records.map((record: any) => {
return {
@@ -126,8 +134,16 @@ export default function PurchaseHistoryPanel({
<ResponsiveContainer width="100%" height={500}>
<BarChart data={purchaseHistoryData}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<YAxis
tickFormatter={(value, index) =>
formatCurrency(value, {
currency: currency
})?.toString() ?? ''
}
/>
<Tooltip
formatter={(label, payload) => tooltipFormatter(label, currency)}
/>
<Legend />
<Bar
dataKey="unit_price"

View File

@@ -12,6 +12,7 @@ import {
} from 'recharts';
import { CHART_COLORS } from '../../../components/charts/colors';
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
import { formatCurrency, renderDate } from '../../../defaults/formatters';
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
import { useTable } from '../../../hooks/UseTable';
@@ -60,6 +61,13 @@ export default function SaleHistoryPanel({ part }: { part: any }): ReactNode {
];
}, []);
const currency: string = useMemo(() => {
if (table.records.length === 0) {
return '';
}
return table.records[0].sale_price_currency;
}, [table.records]);
const saleHistoryData = useMemo(() => {
return table.records.map((record: any) => {
return {
@@ -90,8 +98,16 @@ export default function SaleHistoryPanel({ part }: { part: any }): ReactNode {
<ResponsiveContainer width="100%" height={500}>
<BarChart data={saleHistoryData}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<YAxis
tickFormatter={(value, index) =>
formatCurrency(value, {
currency: currency
})?.toString() ?? ''
}
/>
<Tooltip
formatter={(label, payload) => tooltipFormatter(label, currency)}
/>
<Legend />
<Bar
dataKey="sale_price"

View File

@@ -11,6 +11,8 @@ import {
} from 'recharts';
import { CHART_COLORS } from '../../../components/charts/colors';
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
import { formatCurrency } from '../../../defaults/formatters';
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState';
@@ -29,6 +31,13 @@ export default function SupplierPricingPanel({ part }: { part: any }) {
return SupplierPriceBreakColumns();
}, []);
const currency: string = useMemo(() => {
if (table.records.length === 0) {
return '';
}
return table.records[0].currency;
}, [table.records]);
const supplierPricingData = useMemo(() => {
return table.records.map((record: any) => {
return {
@@ -58,8 +67,16 @@ export default function SupplierPricingPanel({ part }: { part: any }) {
<ResponsiveContainer width="100%" height={500}>
<BarChart data={supplierPricingData}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<YAxis
tickFormatter={(value, index) =>
formatCurrency(value, {
currency: currency
})?.toString() ?? ''
}
/>
<Tooltip
formatter={(label, payload) => tooltipFormatter(label, currency)}
/>
<Bar
dataKey="unit_price"
fill={CHART_COLORS[0]}

View File

@@ -12,6 +12,7 @@ import {
} from 'recharts';
import { CHART_COLORS } from '../../../components/charts/colors';
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
import { formatCurrency } from '../../../defaults/formatters';
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
import { ModelType } from '../../../enums/ModelType';
@@ -99,8 +100,18 @@ export default function VariantPricingPanel({
<ResponsiveContainer width="100%" height={500}>
<BarChart data={variantPricingData}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<YAxis
tickFormatter={(value, index) =>
formatCurrency(value, {
currency: pricing?.currency
})?.toString() ?? ''
}
/>
<Tooltip
formatter={(label, payload) =>
tooltipFormatter(label, pricing?.currency)
}
/>
<Legend />
<Bar
dataKey="pmin"

View File

@@ -103,6 +103,7 @@ export const AdminCenter = Loadable(
export const NotFound = Loadable(lazy(() => import('./pages/NotFound')));
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In')));
export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset')));
export const Set_Password = Loadable(
@@ -163,6 +164,7 @@ export const routes = (
</Route>
<Route path="/" errorElement={<ErrorPage />}>
<Route path="/login" element={<Login />} />,
<Route path="/logout" element={<Logout />} />,
<Route path="/logged-in" element={<Logged_In />} />
<Route path="/reset-password" element={<Reset />} />
<Route path="/set-password" element={<Set_Password />} />

View File

@@ -1,37 +0,0 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { setApiDefaults } from '../App';
import { fetchGlobalStates } from './states';
interface SessionStateProps {
token?: string;
setToken: (newToken?: string) => void;
clearToken: () => void;
hasToken: () => boolean;
}
/*
* State manager for user login information.
*/
export const useSessionState = create<SessionStateProps>()(
persist(
(set, get) => ({
token: undefined,
clearToken: () => {
set({ token: undefined });
},
setToken: (newToken) => {
set({ token: newToken });
setApiDefaults();
fetchGlobalStates();
},
hasToken: () => !!get().token
}),
{
name: 'session-state',
storage: createJSONStorage(() => sessionStorage)
}
)
);

View File

@@ -5,9 +5,9 @@ import { create, createStore } from 'zustand';
import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { isLoggedIn } from '../functions/auth';
import { isTrue } from '../functions/conversion';
import { PathParams, apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
import { Setting, SettingsLookup } from './states';
export interface SettingsStateProps {
@@ -29,7 +29,7 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
lookup: {},
endpoint: ApiEndpoints.settings_global_list,
fetchSettings: async () => {
if (!useSessionState.getState().hasToken()) {
if (!isLoggedIn()) {
return;
}
@@ -63,7 +63,7 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
lookup: {},
endpoint: ApiEndpoints.settings_user_list,
fetchSettings: async () => {
if (!useSessionState.getState().hasToken()) {
if (!isLoggedIn()) {
return;
}

View File

@@ -6,8 +6,8 @@ import { StatusCodeListInterface } from '../components/render/StatusRenderer';
import { statusCodeList } from '../defaults/backendMappings';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { isLoggedIn } from '../functions/auth';
import { apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
@@ -24,7 +24,7 @@ export const useGlobalStatusState = create<ServerStateProps>()(
setStatus: (newStatus: StatusLookup) => set({ status: newStatus }),
fetchStatus: async () => {
// Fetch status data for rendering labels
if (!useSessionState.getState().hasToken()) {
if (!isLoggedIn()) {
return;
}

View File

@@ -3,8 +3,8 @@ import { create } from 'zustand';
import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { UserPermissions, UserRoles } from '../enums/Roles';
import { isLoggedIn } from '../functions/auth';
import { apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
import { UserProps } from './states';
interface UserStateProps {
@@ -37,7 +37,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
},
setUser: (newUser: UserProps) => set({ user: newUser }),
fetchUserState: async () => {
if (!useSessionState.getState().hasToken()) {
if (!isLoggedIn()) {
return;
}
@@ -56,7 +56,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
};
set({ user: user });
})
.catch((_error) => {
.catch((error) => {
console.error('Error fetching user data');
});

View File

@@ -1,6 +1,6 @@
import { setApiDefaults } from '../App';
import { isLoggedIn } from '../functions/auth';
import { useServerApiState } from './ApiState';
import { useSessionState } from './SessionState';
import { useGlobalSettingsState, useUserSettingsState } from './SettingsState';
import { useGlobalStatusState } from './StatusState';
import { useUserState } from './UserState';
@@ -126,7 +126,7 @@ export type SettingsLookup = {
* Necessary on login, or if locale is changed.
*/
export function fetchGlobalStates() {
if (!useSessionState.getState().hasToken()) {
if (!isLoggedIn()) {
return;
}

View File

@@ -5,10 +5,10 @@ import { BrowserRouter } from 'react-router-dom';
import { queryClient } from '../App';
import { BaseContext } from '../contexts/BaseContext';
import { defaultHostList } from '../defaults/defaultHostList';
import { isLoggedIn } from '../functions/auth';
import { base_url } from '../main';
import { routes } from '../router';
import { useLocalState } from '../states/LocalState';
import { useSessionState } from '../states/SessionState';
import {
useGlobalSettingsState,
useUserSettingsState
@@ -28,20 +28,19 @@ export default function DesktopAppView() {
// Server Session
const [fetchedServerSession, setFetchedServerSession] = useState(false);
const sessionState = useSessionState.getState();
const [token] = sessionState.token ? [sessionState.token] : [null];
useEffect(() => {
if (Object.keys(hostList).length === 0) {
useLocalState.setState({ hostList: defaultHostList });
}
if (token && !fetchedServerSession) {
if (isLoggedIn() && !fetchedServerSession) {
setFetchedServerSession(true);
fetchUserState();
fetchGlobalSettings();
fetchUserSettings();
}
}, [token, fetchedServerSession]);
}, [fetchedServerSession]);
return (
<BaseContext>

View File

@@ -4,11 +4,10 @@ import { classicUrl, user } from './defaults';
test('CUI - Index', async ({ page }) => {
await page.goto(`${classicUrl}/api/`);
await page.goto(`${classicUrl}/index/`);
await expect(page).toHaveTitle('InvenTree Demo Server | Sign In');
await expect(
page.getByRole('heading', { name: 'InvenTree Demo Server' })
).toBeVisible();
await page.goto(`${classicUrl}/index/`, { timeout: 10000 });
console.log('Page title:', await page.title());
await expect(page).toHaveTitle(RegExp('^InvenTree.*Sign In$'));
await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible();
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);

View File

@@ -1,6 +1,17 @@
export const classicUrl = 'http://127.0.0.1:8000';
export const baseUrl = `${classicUrl}/platform`;
export const loginUrl = `${baseUrl}/login`;
export const logoutUrl = `${baseUrl}/logout`;
export const homeUrl = `${baseUrl}/home`;
export const user = {
name: 'Ally Access',
username: 'allaccess',
password: 'nolimits'
};
export const adminuser = {
username: 'admin',
password: 'inventree'
};

View File

@@ -0,0 +1,37 @@
import { expect } from './baseFixtures.js';
import { baseUrl, loginUrl, logoutUrl, user } from './defaults';
/*
* Perform form based login operation from the "login" URL
*/
export const doLogin = async (page, username?: string, password?: string) => {
username = username ?? user.username;
password = password ?? user.password;
await page.goto(logoutUrl);
await page.goto(loginUrl);
await expect(page).toHaveTitle(RegExp('^InvenTree.*$'));
await page.waitForURL('**/platform/login');
await page.getByLabel('username').fill(username);
await page.getByLabel('password').fill(password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform/home');
await page.waitForTimeout(250);
};
/*
* Perform a quick login based on passing URL parameters
*/
export const doQuickLogin = async (
page,
username?: string,
password?: string
) => {
username = username ?? user.username;
password = password ?? user.password;
// await page.goto(logoutUrl);
await page.goto(`${baseUrl}/login/?login=${username}&password=${password}`);
await page.waitForURL('**/platform/home');
await page.waitForTimeout(250);
};

View File

@@ -1,28 +1,37 @@
import { expect, test } from './baseFixtures.js';
import { classicUrl, user } from './defaults.js';
import { baseUrl, user } from './defaults.js';
import { doLogin, doQuickLogin } from './login.js';
test('PUI - Basic test via django', async ({ page }) => {
await page.goto(`${classicUrl}/platform/`);
await expect(page).toHaveTitle('InvenTree Demo Server');
await page.waitForURL('**/platform/*');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform/*');
await page.goto(`${classicUrl}/platform/`);
test('PUI - Basic Login Test', async ({ page }) => {
await doLogin(page);
await expect(page).toHaveTitle('InvenTree Demo Server');
});
// Check that the username is provided
await page.getByText(user.username);
test('PUI - Basic test', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/*');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page).toHaveTitle(RegExp('^InvenTree'));
// Go to the dashboard
await page.goto(baseUrl);
await page.waitForURL('**/platform');
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page
.getByRole('heading', { name: `Welcome to your Dashboard, ${user.name}` })
.click();
});
test('PUI - Quick Login Test', async ({ page }) => {
await doQuickLogin(page);
// Check that the username is provided
await page.getByText(user.username);
await expect(page).toHaveTitle(RegExp('^InvenTree'));
// Go to the dashboard
await page.goto(baseUrl);
await page.waitForURL('**/platform');
await page
.getByRole('heading', { name: `Welcome to your Dashboard, ${user.name}` })
.click();
});

View File

@@ -1,28 +1,16 @@
import { expect, systemKey, test } from './baseFixtures.js';
import { user } from './defaults.js';
import { systemKey, test } from './baseFixtures.js';
import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
test('PUI - Quick Command', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
.click();
await page.waitForTimeout(500);
await doQuickLogin(page);
// Open Spotlight with Keyboard Shortcut
await page.locator('body').press(`${systemKey}+k`);
await page.waitForTimeout(200);
await page
.getByRole('button', { name: 'Go to the InvenTree dashboard' })
.click()
.click();
await page.locator('p').filter({ hasText: 'Dashboard' }).waitFor();
await page.waitForURL('**/platform/dashboard');
@@ -44,19 +32,8 @@ test('PUI - Quick Command', async ({ page }) => {
await page.waitForURL('**/platform/dashboard');
});
test('PUI - Quick Command - no keys', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform/*');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform');
// wait for the page to load - 0.5s
await page.waitForTimeout(500);
test('PUI - Quick Command - No Keys', async ({ page }) => {
await doQuickLogin(page);
// Open Spotlight with Button
await page.getByRole('button', { name: 'Open spotlight' }).click();
@@ -118,7 +95,7 @@ test('PUI - Quick Command - no keys', async ({ page }) => {
await page.waitForURL('https://docs.inventree.org/**');
// Test addition of new actions
await page.goto('./platform/playground');
await page.goto(`${baseUrl}/playground`);
await page.locator('p').filter({ hasText: 'Playground' }).waitFor();
await page.getByRole('button', { name: 'Spotlight actions' }).click();
await page.getByRole('button', { name: 'Register extra actions' }).click();

View File

@@ -1,20 +1,15 @@
import { expect, test } from './baseFixtures.js';
import { user } from './defaults.js';
import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
test('PUI - Parts', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.goto('./platform/home');
await doQuickLogin(page);
await page.goto(`${baseUrl}/home`);
await page.getByRole('tab', { name: 'Parts' }).click();
await page.goto('./platform/part/');
await page.waitForURL('**/platform/part/category/index/details');
await page.goto('./platform/part/category/index/parts');
await page.goto(`${baseUrl}/part/category/index/parts`);
await page.getByText('1551ABK').click();
await page.getByRole('tab', { name: 'Allocations' }).click();
await page.getByRole('tab', { name: 'Used In' }).click();
@@ -39,15 +34,10 @@ test('PUI - Parts', async ({ page }) => {
});
test('PUI - Parts - Manufacturer Parts', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/84/manufacturers`);
await page.goto('./platform/part/84/manufacturers');
await page.getByRole('tab', { name: 'Manufacturers' }).click();
await page.getByText('Hammond Manufacturing').click();
await page.getByRole('tab', { name: 'Parameters' }).click();
@@ -57,15 +47,10 @@ test('PUI - Parts - Manufacturer Parts', async ({ page }) => {
});
test('PUI - Parts - Supplier Parts', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/15/suppliers`);
await page.goto('./platform/part/15/suppliers');
await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByRole('cell', { name: 'DIG-84670-SJI' }).click();
await page.getByRole('tab', { name: 'Received Stock' }).click(); //
@@ -75,15 +60,10 @@ test('PUI - Parts - Supplier Parts', async ({ page }) => {
});
test('PUI - Sales', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await doQuickLogin(page);
await page.goto(`${baseUrl}/sales/`);
await page.goto('./platform/sales/');
await page.waitForURL('**/platform/sales/**');
await page.waitForURL('**/platform/sales/index/salesorders');
await page.getByRole('tab', { name: 'Return Orders' }).click();
@@ -131,13 +111,7 @@ test('PUI - Sales', async ({ page }) => {
});
test('PUI - Scanning', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await doQuickLogin(page);
await page.getByLabel('Homenav').click();
await page.getByRole('button', { name: 'System Information' }).click();
@@ -158,13 +132,8 @@ test('PUI - Scanning', async ({ page }) => {
});
test('PUI - Admin', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/*');
await page.getByLabel('username').fill('admin');
await page.getByLabel('password').fill('inventree');
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
// Note here we login with admin access
await doQuickLogin(page, 'admin', 'inventree');
// User settings
await page.getByRole('button', { name: 'admin' }).click();
@@ -213,13 +182,7 @@ test('PUI - Admin', async ({ page }) => {
});
test('PUI - Language / Color', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/*');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await doQuickLogin(page);
await page.getByRole('button', { name: 'Ally Access' }).click();
await page.getByRole('menuitem', { name: 'Logout' }).click();
@@ -253,15 +216,9 @@ test('PUI - Language / Color', async ({ page }) => {
});
test('PUI - Company', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await doQuickLogin(page);
await page.goto('./platform/company/1/details');
await page.goto(`${baseUrl}/company/1/details`);
await page.getByLabel('Details').getByText('DigiKey Electronics').waitFor();
await page.getByRole('cell', { name: 'https://www.digikey.com/' }).waitFor();
await page.getByRole('tab', { name: 'Supplied Parts' }).click();

View File

@@ -1,16 +1,11 @@
import { expect, test } from './baseFixtures.js';
import { user } from './defaults.js';
import { baseUrl, user } from './defaults.js';
import { doQuickLogin } from './login.js';
test('PUI - Stock', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await doQuickLogin(page);
await page.goto('./platform/stock');
await page.goto(`${baseUrl}/stock`);
await page.waitForURL('**/platform/stock/location/index/details');
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page.getByRole('cell', { name: '1551ABK' }).click();
@@ -24,13 +19,7 @@ test('PUI - Stock', async ({ page }) => {
});
test('PUI - Build', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await doQuickLogin(page);
await page.getByRole('tab', { name: 'Build' }).click();
await page.getByText('Widget Assembly Variant').click();
@@ -44,13 +33,7 @@ test('PUI - Build', async ({ page }) => {
});
test('PUI - Purchasing', async ({ page }) => {
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await doQuickLogin(page);
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('cell', { name: 'PO0012' }).click();