From 62b0492a2b7cba039978f688c5e218ef29195f60 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 22 May 2026 01:44:24 +0200 Subject: [PATCH 1/5] realign user API endpoints (#11963) * realign user API endpoints to make it clearer which one are only applicable to the current user * fix name * bump api * fix test * fix reference * fix test exception * update ref * reduce breakage * re-add legacy urls till next `breaking` (cherry picked from commit 9908870a81ee3036d7217797c2cf38ccd477e25f) --- docs/docs/api/index.md | 4 +- src/backend/InvenTree/InvenTree/schema.py | 21 +++++ src/backend/InvenTree/users/api.py | 37 ++++++++- src/frontend/lib/enums/ApiEndpoints.tsx | 9 +- .../AccountSettings/AccountDetailPanel.tsx | 2 +- src/frontend/src/states/LocalState.tsx | 2 +- src/frontend/src/states/UserState.tsx | 2 +- .../src/tables/settings/ApiTokenTable.tsx | 3 +- src/frontend/tests/baseFixtures.ts | 2 +- .../tests/pages/pui_dashboard.spec.ts | 83 +++++++++++++++++++ src/performance/tests.py | 1 + 11 files changed, 151 insertions(+), 15 deletions(-) diff --git a/docs/docs/api/index.md b/docs/docs/api/index.md index 58c21fa9477e..256eddd9cfda 100644 --- a/docs/docs/api/index.md +++ b/docs/docs/api/index.md @@ -54,7 +54,7 @@ Each user is assigned an authentication token which can be used to access the AP If a user does not know their access token, it can be requested via the API interface itself, using a basic authentication request. -To obtain a valid token, perform a GET request to `/api/user/token/`. No data are required, but a valid username / password combination must be supplied in the authentication headers. +To obtain a valid token, perform a GET request to `/api/user/me/token/`. No data are required, but a valid username / password combination must be supplied in the authentication headers. !!! info "Credentials" Ensure that a valid username:password combination are supplied as basic authorization headers. @@ -146,7 +146,7 @@ r:delete:stock Users can only perform REST API actions which align with their assigned [role permissions](../settings/permissions.md#roles). Once a user has *authenticated* via the API, a list of the available roles can be retrieved from: -`/api/user/roles/` +`/api/user/me/roles/` For example, when accessing the API from a *superuser* account: diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py index 8ec73921309c..357e4ad6885f 100644 --- a/src/backend/InvenTree/InvenTree/schema.py +++ b/src/backend/InvenTree/InvenTree/schema.py @@ -329,3 +329,24 @@ def schema_for_view_output_options(view_class): view_class ) return extended_view + + +def exclude_from_schema(klass: type, alternative_path: str) -> type: + """Decorator to exclude a view from the OpenAPI schema. + + This is used to hide legacy endpoints from the schema, while still retaining them for backwards compatibility. + """ + + class LegacyView(klass): + """Dummy doc.""" + + LegacyView.__name__ = klass.__name__ + ' - Legacy' + LegacyView.__doc__ = f'This is a legacy endpoint, retained for backwards compatibility. Consider migrating to the new endpoint under {alternative_path}.' + + # Exclude all default operations from the schema + for operation in ['get', 'post', 'put', 'patch', 'delete']: + if hasattr(klass, operation): + LegacyView = extend_schema_view(**{operation: extend_schema(exclude=True)})( + LegacyView + ) + return LegacyView diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 42201ed09554..ee4cb4cc36cc 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -33,6 +33,7 @@ SerializerContextMixin, UpdateAPI, ) +from InvenTree.schema import exclude_from_schema from InvenTree.settings import FRONTEND_URL_BASE from users.models import ApiToken, Owner, RuleSet, UserProfile from users.serializers import ( @@ -506,8 +507,38 @@ def get_object(self): user_urls = [ - path('roles/', RoleDetails.as_view(), name='api-user-roles'), - path('token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'), + # Legacy endpoints (to avoid breaking existing API clients) + # TODO @matmair - remove these legacy endpoints in the next breaking release + path( + 'roles/', + exclude_from_schema(RoleDetails, '/api/user/me/roles/').as_view(), + name='api-user-roles_legacy', + ), + path( + 'token/', + ensure_csrf_cookie( + exclude_from_schema(GetAuthToken, '/api/user/me/token/').as_view() + ), + name='api-token_legacy', + ), + path( + 'profile/', + exclude_from_schema(UserProfileDetail, '/api/user/me/profile/').as_view(), + name='api-user-profile_legacy', + ), + # Individual user endpoints + path( + 'me/', + include([ + path('profile/', UserProfileDetail.as_view(), name='api-user-profile'), + path('roles/', RoleDetails.as_view(), name='api-user-roles'), + path( + 'token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token' + ), + path('', MeUserDetail.as_view(), name='api-user-me'), + ]), + ), + # User related endpoints path( 'tokens/', include([ @@ -515,8 +546,6 @@ def get_object(self): path('', TokenListView.as_view(), name='api-token-list'), ]), ), - path('me/', MeUserDetail.as_view(), name='api-user-me'), - path('profile/', UserProfileDetail.as_view(), name='api-user-profile'), path( 'owner/', include([ diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index e7adf3f0b8d0..2cb8d4a5a357 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -12,12 +12,13 @@ export enum ApiEndpoints { // User API endpoints user_list = 'user/', user_set_password = 'user/:id/set-password/', - user_me = 'user/me/', - user_profile = 'user/profile/', - user_roles = 'user/roles/', - user_token = 'user/token/', user_tokens = 'user/tokens/', user_simple_login = 'email/generate/', + // Individual user endpoints + user_me_profile = 'user/me/profile/', + user_me_roles = 'user/me/roles/', + user_me_token = 'user/me/token/', + user_me = 'user/me/', // User auth endpoints auth_base = '/auth/', diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx index f879e7790682..062a73de7451 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx @@ -53,7 +53,7 @@ export function AccountDetailPanel() { const editProfile = useEditApiFormModal({ title: t`Edit Profile Information`, - url: ApiEndpoints.user_profile, + url: ApiEndpoints.user_me_profile, onFormSuccess: fetchUserState, fields: profileFields, successMessage: t`Profile details updated` diff --git a/src/frontend/src/states/LocalState.tsx b/src/frontend/src/states/LocalState.tsx index 869889439fb8..4519a59f11e7 100644 --- a/src/frontend/src/states/LocalState.tsx +++ b/src/frontend/src/states/LocalState.tsx @@ -165,7 +165,7 @@ pushes changes in user profile to backend function patchUser(key: 'language' | 'theme' | 'widgets', val: any) { const uid = useUserState.getState().userId(); if (uid) { - api.patch(apiUrl(ApiEndpoints.user_profile), { [key]: val }); + api.patch(apiUrl(ApiEndpoints.user_me_profile), { [key]: val }); } else { console.log('user not logged in, not patching'); } diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx index 16cfd75bde73..f6e309861ac7 100644 --- a/src/frontend/src/states/UserState.tsx +++ b/src/frontend/src/states/UserState.tsx @@ -104,7 +104,7 @@ export const useUserState = create((set, get) => ({ // Fetch role data await api - .get(apiUrl(ApiEndpoints.user_roles)) + .get(apiUrl(ApiEndpoints.user_me_roles)) .then((response) => { if (response.status == 200) { const user: UserProps = get().user as UserProps; diff --git a/src/frontend/src/tables/settings/ApiTokenTable.tsx b/src/frontend/src/tables/settings/ApiTokenTable.tsx index 9736dded124e..2084326a0d29 100644 --- a/src/frontend/src/tables/settings/ApiTokenTable.tsx +++ b/src/frontend/src/tables/settings/ApiTokenTable.tsx @@ -26,7 +26,8 @@ export function ApiTokenTable({ const [opened, { open, close }] = useDisclosure(false); const generateToken = useCreateApiFormModal({ - url: ApiEndpoints.user_tokens, + url: ApiEndpoints.user_me_token, + method: 'GET', title: t`Generate Token`, fields: { name: {} }, successMessage: t`Token generated`, diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts index e8bcca39e2ac..e0f85e3b037c 100644 --- a/src/frontend/tests/baseFixtures.ts +++ b/src/frontend/tests/baseFixtures.ts @@ -70,7 +70,7 @@ export const test = baseTest.extend({ !msg.text().includes('/this/does/not/exist.js') && !url.includes('/this/does/not/exist.js') && !url.includes('/api/user/me/') && - !url.includes('/api/user/token/') && + !url.includes('/api/user/me/token/') && !url.includes('/api/auth/v1/auth/login') && !url.includes('/api/auth/v1/auth/session') && !url.includes('/api/auth/v1/account/authenticators/totp') && diff --git a/src/frontend/tests/pages/pui_dashboard.spec.ts b/src/frontend/tests/pages/pui_dashboard.spec.ts index acc3547e4b2d..0279acf9dedc 100644 --- a/src/frontend/tests/pages/pui_dashboard.spec.ts +++ b/src/frontend/tests/pages/pui_dashboard.spec.ts @@ -94,3 +94,86 @@ test('Dashboard - Plugins', async ({ browser }) => { await page.getByRole('heading', { name: 'Sample Dashboard Item' }).waitFor(); await page.getByText('Hello world! This is a sample').waitFor(); }); + +test('Dashboard - Preserve widget sizes', async ({ browser }) => { + // Regression: addWidget previously snapped every existing widget back to + // its minW/minH. Fix is in DashboardLayout.tsx::addWidget (overrideSize=false). + const TARGET_W = 10; + const TARGET_H = 6; + + const readLayouts = (page: Page) => + page.evaluate(() => { + const raw = localStorage.getItem('session-settings'); + return raw ? (JSON.parse(raw)?.state?.layouts ?? {}) : {}; + }); + + const user = allaccessuser; + + const page = await doCachedLogin(browser, { + user: user + }); + await resetDashboard(page); + + // Add widget A; this also persists to the backend user profile. + await page.getByLabel('dashboard-menu').click(); + await page.getByRole('menuitem', { name: 'Add Widget' }).click(); + await page.getByLabel('dashboard-widgets-filter-input').fill('overdue order'); + await page.getByLabel('add-widget-ovr-so').click(); + await page.getByRole('banner').getByRole('button').click(); + await page.getByText('Overdue Sales Orders').waitFor(); + await page.waitForTimeout(100); + + // Inflate widget A on the backend profile and reload. The auth flow on + // page load rehydrates layouts from the profile, not localStorage, so a + // localStorage-only edit would be wiped out on reload. + const current = await readLayouts(page); + const inflated: Record = {}; + for (const bp of Object.keys(current)) { + inflated[bp] = current[bp].map((it: any) => + it?.i === 'ovr-so' ? { ...it, w: TARGET_W, h: TARGET_H } : it + ); + } + + const api = createApi({ + username: user.username, + password: user.testcred + }); + + (await api).patch('user/me/profile/', { + data: { + widgets: { + widgets: ['ovr-so'], + layouts: inflated + } + } + }); + + await page.reload(); + await page.getByText('Overdue Sales Orders').waitFor(); + await page.waitForTimeout(100); + + // Sanity: profile rehydration produced the inflated values. + for (const [bp, items] of Object.entries(await readLayouts(page))) { + const entry = (items as any[]).find((i) => i?.i === 'ovr-so'); + + console.log('entry:', bp, entry); + + expect(entry?.w, `${bp}: ovr-so missing or wrong w`).toBe(TARGET_W); + expect(entry?.h, `${bp}: ovr-so missing or wrong h`).toBe(TARGET_H); + } + + // Add widget B. With the bug, this clobbered widget A back to minW/minH. + await page.getByLabel('dashboard-menu').click(); + await page.getByRole('menuitem', { name: 'Add Widget' }).click(); + await page.getByLabel('dashboard-widgets-filter-input').fill('overdue order'); + await page.getByLabel('add-widget-ovr-po').click(); + await page.getByRole('banner').getByRole('button').click(); + await page.getByText('Overdue Purchase Orders').waitFor(); + await page.waitForTimeout(100); + + for (const [bp, items] of Object.entries(await readLayouts(page))) { + const entry = (items as any[]).find((i) => i?.i === 'ovr-so'); + expect(entry?.w, `${bp}: ovr-so width was reset`).toBe(TARGET_W); + expect(entry?.h, `${bp}: ovr-so height was reset`).toBe(TARGET_H); + } +}); diff --git a/src/performance/tests.py b/src/performance/tests.py index cd1515243389..ab3d02337244 100644 --- a/src/performance/tests.py +++ b/src/performance/tests.py @@ -4,6 +4,7 @@ import os import pytest + from inventree.api import InvenTreeAPI server = os.environ.get('INVENTREE_PYTHON_TEST_SERVER', 'http://127.0.0.1:12345') From e96142f05ea591c4fff35d0d8e46f47b4828a9ee Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 22 May 2026 02:01:01 +0200 Subject: [PATCH 2/5] remove fallback --- CHANGELOG.md | 1 + src/backend/InvenTree/users/api.py | 20 -------------------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c70ced72de2a..63b7aae55adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#11111](https://github.com/inventree/InvenTree/pull/11111) - removes legacy metadata APIs - [#9814](https://github.com/inventree/InvenTree/pull/9814) - removes legacy config path support - [#11667](https://github.com/inventree/InvenTree/pull/11667) - removes legacy url patterns +- [#11985](https://github.com/inventree/InvenTree/pull/11985) - removes legacy user endpoint fallback ## Unreleased - YYYY-MM-DD diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index ee4cb4cc36cc..0f4f2540faff 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -33,7 +33,6 @@ SerializerContextMixin, UpdateAPI, ) -from InvenTree.schema import exclude_from_schema from InvenTree.settings import FRONTEND_URL_BASE from users.models import ApiToken, Owner, RuleSet, UserProfile from users.serializers import ( @@ -507,25 +506,6 @@ def get_object(self): user_urls = [ - # Legacy endpoints (to avoid breaking existing API clients) - # TODO @matmair - remove these legacy endpoints in the next breaking release - path( - 'roles/', - exclude_from_schema(RoleDetails, '/api/user/me/roles/').as_view(), - name='api-user-roles_legacy', - ), - path( - 'token/', - ensure_csrf_cookie( - exclude_from_schema(GetAuthToken, '/api/user/me/token/').as_view() - ), - name='api-token_legacy', - ), - path( - 'profile/', - exclude_from_schema(UserProfileDetail, '/api/user/me/profile/').as_view(), - name='api-user-profile_legacy', - ), # Individual user endpoints path( 'me/', From aab46e99473ffc325626b62967f8eda8977b06d2 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 22 May 2026 02:08:09 +0200 Subject: [PATCH 3/5] fix style --- src/performance/tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/performance/tests.py b/src/performance/tests.py index ab3d02337244..cd1515243389 100644 --- a/src/performance/tests.py +++ b/src/performance/tests.py @@ -4,7 +4,6 @@ import os import pytest - from inventree.api import InvenTreeAPI server = os.environ.get('INVENTREE_PYTHON_TEST_SERVER', 'http://127.0.0.1:12345') From 6e7f6c18f2e10b40eb44c9d320884a4425f5d620 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 25 May 2026 23:48:27 +0200 Subject: [PATCH 4/5] fix style --- src/backend/InvenTree/users/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 2fd38d916e40..fb2a2fc0b5ff 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -33,7 +33,6 @@ SerializerContextMixin, UpdateAPI, ) -from InvenTree.schema import exclude_from_schema from InvenTree.settings import FRONTEND_URL_BASE from users.models import ApiToken, Owner, RuleSet, UserProfile from users.serializers import ( From 46fe8e6da68b55885fde69d617e7d2b4a5bc7f2d Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 26 May 2026 01:36:03 +0200 Subject: [PATCH 5/5] fix performance test --- src/performance/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/performance/tests.py b/src/performance/tests.py index db2dd8896c60..e05c32391a60 100644 --- a/src/performance/tests.py +++ b/src/performance/tests.py @@ -50,7 +50,7 @@ def test_api_auth_performance(): '/api/order/so/shipment/', #'/api/order/po/', #'/api/order/po-line/', - '/api/user/roles/', + '/api/user/me/roles/', '/api/parameter/', '/api/parameter/template/', ], @@ -77,7 +77,7 @@ def test_api_list_performance(url): '/api/order/so/shipment/', '/api/order/po/', '/api/order/po-line/', - '/api/user/roles/', + '/api/user/me/roles/', '/api/parameter/', '/api/parameter/template/', ],