Compare commits

...

47 Commits

Author SHA1 Message Date
Oliver 01bf600004
Backport (#11686)
* Ensure the MeUserSerializer correctly marks fields as read-only

* fix behaviour

* Add note to plugin docs.

* Adjust logic for PluginListTable

* Add superuser scope to PluginInstall API endpoint

* Update unit test for API endpoint

* Explicitly set PLUGINS_INSTALL_DISABLED if PLUGINS_ENABLED = False

* Check for superuser permission in installer.py

* Additional user checks

* Sanitize package name to protect against OS command injection

* fix(security): use SandboxedEnvironment for PART_NAME_FORMAT rendering

- Switch jinja2.Environment to jinja2.sandbox.SandboxedEnvironment in
  part/helpers.py to prevent SSTI via template tags in PART_NAME_FORMAT.
- Set pk=1 on the dummy Part instance in the validator to ensure
  conditional expressions like {% if part.pk %} are properly evaluated
  during validation, closing the sandbox bypass vector.

Fixes GHSA-84jh-x777-8pqq

* Disable some unit tests for backport

* Fix SSRF in remote image download

Add IP address validation to prevent Server-Side Request Forgery
when downloading images from remote URLs. The resolved IP is now
checked against private, loopback, link-local, and reserved ranges
before connecting.

Redirects are followed manually (up to 5 hops) with SSRF validation
at each step, preventing redirect-based bypass of URL format checks.

* Style fixes

* fix styles

* fix test

* Reintroduce unit tests

---------

Co-authored-by: Paul <morimori-dev@github.com>
Co-authored-by: tikket1 <chrisveres1@gmail.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
2026-04-08 07:09:28 +10:00
github-actions[bot] 6bb6bcca2e
docs: add more details around staff / superuser roles and their dangers (#11658) (#11660)
(cherry picked from commit a8cef43484)

Co-authored-by: Matthias Mair <code@mjmair.com>
2026-04-02 11:30:55 +11:00
github-actions[bot] d737a7d7a2
[Doc] Clarify Stocktake date (#11644) (#11645)
(cherry picked from commit e4ac02a840)

Co-authored-by: gunstr <41468206+gunstr@users.noreply.github.com>
2026-03-31 19:57:14 +11:00
github-actions[bot] 50ac52f09a
chore(deps): bump cryptography from 46.0.5 to 46.0.6 in /src/backend (#11619) (#11625)
* chore(deps): bump cryptography from 46.0.5 to 46.0.6 in /src/backend

Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.5 to 46.0.6.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.6
  dependency-type: direct:production
...



* fix style

---------




(cherry picked from commit b4f230753f)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
2026-03-30 09:40:50 +11:00
github-actions[bot] da1be76352
Fix order of hooks for stabilizing table columns (#11482) (#11622)
* Fix order of hooks for stabilizing table columns

Ref: https://github.com/icflorescu/mantine-datatable/issues/759

* Reset column ordering and widths when the component is mounted

---------


(cherry picked from commit 571b46c9ff)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
2026-03-29 15:39:20 +11:00
github-actions[bot] b19528b341
feat(backend): add inventree version to every response (#11611) (#11620)
* feat(backend): add inventree version to every response

* add more info

(cherry picked from commit e3c9a35bae)

Co-authored-by: Matthias Mair <code@mjmair.com>
2026-03-29 14:45:22 +11:00
github-actions[bot] c5ff2f514f
chore(deps): bump requests from 2.32.5 to 2.33.0 in /src/backend (#11609) (#11610)
* chore(deps): bump requests from 2.32.5 to 2.33.0 in /src/backend

Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:production
...



* style fix

* reduce diff

---------




(cherry picked from commit d2e8c75de4)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
2026-03-27 07:47:08 +11:00
github-actions[bot] badd6e431b
Restrict queryset for DataImportSession (#11602) (#11605)
* Restrict queryset for DataImportSession

- Only allow non-staff users to see their own sessions

* Add unit test

* raise PermissionDenied if no user info available

(cherry picked from commit b98fc9c7a0)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-03-24 23:51:20 +11:00
Matthias Mair add122faf1
Add GH_TOKEN to release workflow 2026-03-23 22:05:28 +01:00
Oliver b810b47a4a
[API] Category star fix (#11588) (#11589)
* [API] Bug fix for PartStar and PartCategoryStar

- Logic refactor and fixes

* Add playwright tests

* Remove debug statements

* Revert API string changes
2026-03-22 08:28:24 +11:00
Oliver 651e1c0dac
Bump software version to 1.2.7 (#11587) 2026-03-22 00:06:42 +11:00
Oliver 2bc9e0f0d2
Security backport (#11584)
* Improvements for get_bulk_queryset (#11581)

* Improvements for get_bulk_queryset

- Limit scope to base view queryset
- Remove ability to provide arbitrary filters
- Remove feedback if zero items are found

* Adjust unit test

* Remove filter test

* Update CHANGELOG.md

* Refactoring for report helper functions (#11579)

* Refactoring for media file report helper functions

* Updated unit tests

* Improved error handling

* Generic path return for asset

* Fix return type annotations

* Fix existing test

* Tweaked unit test

* Collect static files in CI

* Run static step for all DB tests

* Update action.yaml

* Fix for action.yaml

* Updated unit tests
2026-03-21 19:45:03 +11:00
github-actions[bot] 604f5fdb45
Adjust DataOutput API endpoint (#11580) (#11582)
* DataOutput API fix

- Prevent non-staff users from accessing unrelated DataOutput instances

* Add unit tests

(cherry picked from commit c5bf915d10)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-03-21 15:35:46 +11:00
github-actions[bot] ee3d01cf73
Save user info (#11572) (#11574)
* Record user info when creating stock item

* Add unit test

* Add playwright test

(cherry picked from commit fc730b9af7)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-03-20 15:53:33 +11:00
github-actions[bot] 3e447066c6
Build line fix (#11566) (#11570)
* Fix for RenderBuildLine

* Simple playwright test

(cherry picked from commit 7f0610502d)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-03-20 09:00:21 +11:00
Oliver 17fd3174fc
Bump InvenTree software version to 1.2.6 (#11564) 2026-03-20 00:46:05 +11:00
github-actions[bot] c92f1c3413
[API] Bug fix for PartCategory cascade filter (#11562) (#11563)
* Bug fix for PartCategory cascade filter

* Additional unit test

(cherry picked from commit ef2f05a418)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-03-19 23:52:17 +11:00
github-actions[bot] 65f1a4bde5
fix(plugin): use app_name instead of plugin_path when deregistering models (#11536) (#11553)
* fix(plugin): use app_name instead of plugin_path when deregistering models

_deactivate_mixin uses plugin_path (the full dotted module path) as the
key into Django's apps.all_models when removing plugin models during
reload. However, Django registers models under the app_label (the short
app_name), not the full plugin_path.

For plugins with nested module paths (e.g. "myplugin.myplugin"),
plugin_path != app_name. Since apps.all_models is a defaultdict, looking
up plugin_path silently creates an empty OrderedDict, then .pop(model)
raises KeyError because the model was never in that dict — it was
registered under app_name.

This causes recurring KeyError crashes every plugin reload cycle
(~1 minute) for any external plugin with a nested package structure.

The fix:
- Use app_name (already computed at line 98) instead of plugin_path
- Add default None to .pop() for defensive safety
- Consistent with line 123 which already correctly uses app_name

* test(plugin): add unit test for nested plugin path model deregistration

Ensures _deactivate_mixin uses app_name (last path component) instead
of the full plugin_path when looking up models in apps.all_models,
preventing KeyError for external plugins with nested module structures.

* style: fix ruff format for context manager parenthesization

(cherry picked from commit 468f0f9c3b)

Co-authored-by: nino-tan-smartee <nino.tan@smartsourcing.co>
2026-03-18 15:31:04 +11:00
Oliver 8077849fc5
Bump InvenTree software version to 1.2.5 (#11550) 2026-03-18 00:29:07 +11:00
Oliver 4789a202ee
Disable broken unit test (#11549) 2026-03-17 22:40:11 +11:00
Oliver 60f2d9d314
Fix docs link for redis (#11534) 2026-03-16 11:21:47 +11:00
Oliver c173c4e051
Fix stuck-in-past expiry date filter (#11518) (#11523)
* fix stuck-in-past expiry date filter

* also fix OVERDUE_FILTER date problem

Co-authored-by: Jacob Felknor <jacobfelknor073@gmail.com>
2026-03-16 00:12:38 +11:00
github-actions[bot] 73cab199f1
Fix table for s3 backup in docs (#11512) (#11524)
(cherry picked from commit da982dc3dd)

Co-authored-by: Knirps1 <52383078+Knirps1@users.noreply.github.com>
2026-03-14 23:37:28 +11:00
github-actions[bot] e749ece58e
fix: low-privilege user token creation (#11492) (#11521)
* [bug] Users cannot create their own API tokens
Fixes #11486

* fix detection of metadata

* make easier to read

* add handler for IsAuthenticated

* use correct method

* fix style see #11487

* add frontend test

* make test more reliable?

(cherry picked from commit fda3204e33)

Co-authored-by: Matthias Mair <code@mjmair.com>
2026-03-14 12:35:28 +11:00
github-actions[bot] e5ace7d761
Add poppler-utils to packager.io dependencies (#11508) (#11509)
Required for label printing - pdf2image cannot convert PDFs to images without poppler's pdfinfo binary being present on the system.


(cherry picked from commit 6deed010a3)

Co-authored-by: jbell <jbell101@gmail.com>
Co-authored-by: Justyn Bell <justyn.bell@subinitial.com>
2026-03-13 22:33:44 +11:00
github-actions[bot] 5bf41f1463
[UI] Fix hook deps for BuildOutputTable (#11483) (#11495)
- Closes https://github.com/inventree/InvenTree/issues/11280

(cherry picked from commit aafda4bece)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-03-12 13:30:28 +11:00
github-actions[bot] 59f3b9f2f6
fix: wrap create_default_labels in transaction.atomic() savepoint to prevent PostgreSQL transaction abort (#11484) (#11490)
When the plugin registry reloads during an active HTTP request,
create_default_labels() raises ValidationError on duplicate template
inserts. On PostgreSQL this aborts the entire outer atomic() block,
causing TransactionManagementError on all stock operations in that request.

Fixes #11469


(cherry picked from commit a964d6682e)

Co-authored-by: tigger2000ttfn <tigger2000ttfn@gmail.com>
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-03-11 22:28:22 +11:00
Matthias Mair f2fa65ff1d
chore(deps): bump pypdf from 6.7.4 to 6.7.5 in /src/backend (#11454) (#11462)
* chore(deps): bump pypdf from 6.7.4 to 6.7.5 in /src/backend

Bumps [pypdf](https://github.com/py-pdf/pypdf) from 6.7.4 to 6.7.5.
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.7.4...6.7.5)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.7.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix style

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
(cherry picked from commit 3ed21e00cf)

# Conflicts:
#	src/backend/requirements-3.14.txt
#	src/backend/requirements.txt

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 09:44:49 +11:00
github-actions[bot] 9cfec02a69
[API] Enforce pk ordering for API endpoints (#11446) (#11447)
* Add unit test to detect unreliable pagination

* Enforce PK field ordering

- Append 'pk' ordering to InvenTreeOrderingFilter

* Use our ordering filter everywhere

* Simplify ordering options

* Enforce list

* Use last term for ordering checks

* Individual delete to fix mysql issue

(cherry picked from commit 3bbdddf51d)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-03-02 23:07:00 +11:00
github-actions[bot] c35ab145a4
Prevent exchange rate update during migration (#11429) (#11430)
(cherry picked from commit fb3b1189fd)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-02-27 09:36:53 +11:00
Oliver b611960d9f
Bump software version to 1.2.4 (#11402) 2026-02-23 09:10:40 +11:00
Oliver 12da3b01fc
Merge commit from fork (#11400)
- Change one renderer from jinja to django
- Move the other to a sandboxed environment
2026-02-22 23:25:31 +11:00
Oliver 2df9eb80ca
[bug] Fix custom logo and splash (#11391) (#11399)
* Add custom logo and splash to playwright fixtures

* Render custom logo in frontend

* Add playwright tests for customization settings

* Separate playwright run for customization

* Update ci file

* Update playwright config file

* Do not call get_custom_file from withing settings.py

- django app not fully initialized yet
- can cause loops

* Disable default tests (for now)

* Re-enable normal tests

* Update qc checks

* Order of operations?

* Debug output

* Use env

* Add more debug info

* More debug

* Further debug

* Add collectstatic check

* Run normal tests too

* Remove debug prints
2026-02-22 22:46:25 +11:00
github-actions[bot] 44387f3bb4
[bug] Check for null plugin config (#11394) (#11395)
* Check for null plugin config

* Handle potential errors

(cherry picked from commit 2e675ee87a)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-02-22 12:56:57 +11:00
Oliver ed8331e26a
Bump InvenTree software version to 1.2.3 (#11382) 2026-02-19 22:57:39 +11:00
Oliver 228577c871
[UI] Bug fix for stock column ordering (#11376) (#11380)
- Cannot have a blank accessor
2026-02-19 22:09:58 +11:00
github-actions[bot] fd7d0bda0f
Batch update of records during migration (#11371) (#11373)
* Batch update of records during migration

Reduce issues with very large datasets

* Add short-circuit logic

* Filter and prefetch

(cherry picked from commit a4a399022f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-02-19 12:07:16 +11:00
Oliver 90e5169283
Bump software version to 1.2.2 (#11360) 2026-02-18 10:51:42 +11:00
github-actions[bot] 198594d7d3
Prevent migration check if performing DB update operations (#11356) (#11358)
* Prevent migration check if performing DB update operations

* Prevent settings write during data imports

* Prevent exchange rate update during migration processes

* Allow exchange rate updates during tests or shell session

(cherry picked from commit 951586a698)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-02-18 00:27:32 +11:00
github-actions[bot] dededb0aa1
refactor(backend): switch release pipeline to new pkgr version (#11336) (#11355)
* refactor(backend): switch release pipeline to new pkgr version

* fix wrong assign

* fix warnings

* also publish to release artifacts

(cherry picked from commit 1cc1f059c8)

Co-authored-by: Matthias Mair <code@mjmair.com>
2026-02-17 21:48:48 +11:00
github-actions[bot] eb413bcc57
disable traceid gen in unsecure context (#11349) (#11350)
(cherry picked from commit b8cf2db68e)

Co-authored-by: Matthias Mair <code@mjmair.com>
2026-02-17 10:47:32 +11:00
Oliver 9b11cd294e
Bump software version to 1.2.1 (#11313)
* Bump software version to 1.2.1

* Fix style issues

* [CI] Allow more test time

* Disable performance testing

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
2026-02-16 12:46:45 +11:00
github-actions[bot] ccf5b39bf1
[bug] FIx for data migration (#11329) (#11330)
* [bug] FIx for data migration

Prevent writing of default currency to database during migration or backup

* Ignore error

(cherry picked from commit 7a646946c3)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-02-15 22:44:21 +11:00
github-actions[bot] eceaa68c32
Tweak docs for database migration (#11327) (#11328)
(cherry picked from commit 867f5a6cd5)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-02-15 10:50:34 +11:00
github-actions[bot] ccb4ea248b
Update base image in Dockerfile (#11309) (#11322)
* Bump version number to 1.2.0 (#11299)

* Bump version number to 1.2.0

* Update CHANGELOG.md

* Update base image in Dockerfile

Updated base image to the latest version as of 2026-02-12.

* Fix for devcontainer build (#11311) (#11312)

- Install yarn a different way

(cherry picked from commit 2a613809ec)



---------



(cherry picked from commit 723a4be24a)

Co-authored-by: Matthias Mair <code@mjmair.com>
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-14 12:55:47 +11:00
github-actions[bot] 9b3b68c8d8
Fix for devcontainer build (#11311) (#11312)
- Install yarn a different way

(cherry picked from commit 2a613809ec)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2026-02-13 11:17:52 +11:00
Oliver 3752b610c8
Bump version number to 1.2.0 (#11299)
* Bump version number to 1.2.0

* Update CHANGELOG.md
2026-02-12 19:53:08 +11:00
93 changed files with 1523 additions and 601 deletions

View File

@ -37,9 +37,10 @@ RUN apt install -y nodejs npm
# Update to the latest stable node version # Update to the latest stable node version
RUN npm install -g n --ignore-scripts && n lts RUN npm install -g n --ignore-scripts && n lts
# Enable yarn # Install yarn
RUN corepack enable && corepack prepare yarn@stable --activate RUN npm install -g yarn
# Ensure node and yarn are available at the command line
RUN node --version && yarn --version RUN node --version && yarn --version
RUN yarn config set httpTimeout 600000 --home RUN yarn config set httpTimeout 600000 --home

View File

@ -15,6 +15,10 @@ inputs:
required: false required: false
description: 'Install the InvenTree requirements?' description: 'Install the InvenTree requirements?'
default: 'false' default: 'false'
static:
required: false
description: 'Should the static files be built?'
default: 'false'
dev-install: dev-install:
required: false required: false
description: 'Install the InvenTree development requirements?' description: 'Install the InvenTree development requirements?'
@ -103,3 +107,7 @@ runs:
if: ${{ inputs.update == 'true' }} if: ${{ inputs.update == 'true' }}
shell: bash shell: bash
run: invoke update --skip-backup --skip-static run: invoke update --skip-backup --skip-static
- name: Collect static files
if: ${{ inputs.static == 'true' }}
shell: bash
run: invoke static --skip-plugins

View File

@ -23,6 +23,8 @@ env:
INVENTREE_SITE_URL: http://localhost:8000 INVENTREE_SITE_URL: http://localhost:8000
INVENTREE_DEBUG: true INVENTREE_DEBUG: true
use_performance: false
permissions: permissions:
contents: read contents: read
@ -78,7 +80,7 @@ jobs:
- name: Which runner to use? - name: Which runner to use?
id: runner-perf id: runner-perf
# decide if we are running in inventree/inventree -> use codspeed-macro runner else ubuntu-24.04 # decide if we are running in inventree/inventree -> use codspeed-macro runner else ubuntu-24.04
run: echo "runner=$([[ '${{ github.repository }}' == 'inventree/InvenTree' ]] && echo 'codspeed-macro' || echo 'ubuntu-24.04')" >> $GITHUB_OUTPUT run: echo "runner=$([[ '${{ github.repository }}' == 'inventree/InvenTree' ]] && '${{ env.use_performance }}' == 'true' ]] && echo 'codspeed-macro' || echo 'ubuntu-24.04')" >> $GITHUB_OUTPUT
pre-commit: pre-commit:
name: Style [pre-commit] name: Style [pre-commit]
@ -320,6 +322,7 @@ jobs:
apt-dependency: gettext poppler-utils apt-dependency: gettext poppler-utils
dev-install: true dev-install: true
update: true update: true
static: true
npm: true npm: true
- name: Download Python Code For `${WRAPPER_NAME}` - name: Download Python Code For `${WRAPPER_NAME}`
run: git clone --depth 1 https://github.com/inventree/${WRAPPER_NAME} ./${WRAPPER_NAME} run: git clone --depth 1 https://github.com/inventree/${WRAPPER_NAME} ./${WRAPPER_NAME}
@ -376,6 +379,7 @@ jobs:
apt-dependency: gettext poppler-utils apt-dependency: gettext poppler-utils
dev-install: true dev-install: true
update: true update: true
static: true
- name: Data Export Test - name: Data Export Test
uses: ./.github/actions/migration uses: ./.github/actions/migration
- name: Test Translations - name: Test Translations
@ -478,6 +482,7 @@ jobs:
pip-dependency: psycopg django-redis>=5.0.0 pip-dependency: psycopg django-redis>=5.0.0
dev-install: true dev-install: true
update: true update: true
static: true
- name: Run Tests - name: Run Tests
run: invoke dev.test --check --translations run: invoke dev.test --check --translations
- name: Data Export Test - name: Data Export Test
@ -526,6 +531,7 @@ jobs:
pip-dependency: mysqlclient pip-dependency: mysqlclient
dev-install: true dev-install: true
update: true update: true
static: true
- name: Run Tests - name: Run Tests
run: invoke dev.test --check --translations run: invoke dev.test --check --translations
- name: Data Export Test - name: Data Export Test
@ -692,7 +698,13 @@ jobs:
cd src/frontend && npx playwright install --with-deps cd src/frontend && npx playwright install --with-deps
- name: Run Playwright tests - name: Run Playwright tests
id: tests id: tests
run: cd src/frontend && npx nyc playwright test run: |
cd src/frontend
cp ./tests/fixtures/playwright_custom_logo.png ../backend/InvenTree/InvenTree/static/img/playwright_custom_logo.png
cp ./tests/fixtures/playwright_custom_splash.png ../backend/InvenTree/InvenTree/static/img/playwright_custom_splash.png
invoke static
env INVENTREE_CUSTOM_SPLASH="img/playwright_custom_splash.png" INVENTREE_CUSTOM_LOGO="img/playwright_custom_logo.png" npx nyc playwright test --project=customization
npx nyc playwright test --project=chromium --project=firefox
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # pin@v6.0.0 - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # pin@v6.0.0
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }} if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with: with:

View File

@ -85,6 +85,11 @@ jobs:
asset_name: frontend-build.zip asset_name: frontend-build.zip
tag: ${{ github.ref }} tag: ${{ github.ref }}
overwrite: true overwrite: true
- name: Upload frontend to artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # pin@v6.0.0
with:
name: frontend-build
path: src/backend/InvenTree/web/static/frontend-build.zip
- name: Upload Attestation - name: Upload Attestation
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # pin@2.11.3 uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # pin@2.11.3
with: with:
@ -136,3 +141,99 @@ jobs:
asset_name: docs-html.zip asset_name: docs-html.zip
tag: ${{ github.ref }} tag: ${{ github.ref }}
overwrite: true overwrite: true
build-pkgr:
if: github.repository == 'inventree/InvenTree'
name: ${{ matrix.target }}
runs-on: ubuntu-latest
needs: [build, docs]
strategy:
fail-fast: false
matrix:
target:
- ubuntu:22.04
- ubuntu:24.04
- debian:12
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Get frontend artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # pin@v7.0.0
with:
name: frontend-build
- name: Setup
id: setup
env:
NODE_ID: ${{ github.event.release.node_id }}
BRANCH: ${{ github.event.release.target_commitish }}
TARGET: ${{github.event.release.target_commitish}}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: |
# Get info
gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/$REPO/commits/$TARGET > commit.json
# Extract info
echo "INFO extract | Extracting info from github"
DATE=$(jq -r '.commit.committer.date' commit.json)
SIGNATURE=$(jq -r '.commit.verification.signature' commit.json)
FULL_SHA=$(jq -r '.sha' commit.json)
SHA=$(echo "$FULL_SHA" | cut -c1-7)
echo "INFO write | Write VERSION information"
echo "$VERSION" > VERSION
echo "INVENTREE_COMMIT_HASH='$SHA'" >> VERSION
echo "INVENTREE_COMMIT_SHA='$FULL_SHA'" >> VERSION
echo "INVENTREE_COMMIT_DATE='$DATE'" >> VERSION
echo "INVENTREE_PKG_INSTALLER='PKG'" >> VERSION
echo "INVENTREE_PKG_BRANCH='$BRANCH'" >> VERSION
echo "INVENTREE_PKG_TARGET='$TARGET'" >> VERSION
echo "NODE_ID='$NODE_ID'" >> VERSION
echo "SIGNATURE='$SIGNATURE'" >> VERSION
echo "INFO write | Written VERSION information"
echo "### VERSION ###"
cat VERSION
echo "### VERSION ###"
# Move frontend build into place
mkdir -p src/backend/InvenTree/web/static
unzip -qq frontend-build.zip -d src/backend/InvenTree/web/static/web
- name: Package
uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main
id: package
with:
target: ${{ matrix.target }}
version: ${{ steps.setup.outputs.version }}
debug: true
cache_prefix: ${{ github.ref_name }}
env: |
INVENTREE_DB_ENGINE=sqlite3
INVENTREE_DB_NAME=database.sqlite3
INVENTREE_PLUGINS_ENABLED=true
INVENTREE_MEDIA_ROOT=/opt/inventree/media
INVENTREE_STATIC_ROOT=/opt/inventree/static
INVENTREE_BACKUP_DIR=/opt/inventree/backup
INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt
INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
APP_REPO=inventree/InvenTree
- name: Publish to go.packager.io
uses: pkgr/action/publish@3bce081ae512c5020856e237d37b3f5479d4aa71 # pin@main
with:
target: ${{ matrix.target }}
token: ${{ secrets.PACKAGER_RELEASE_TOKEN }}
repository: inventree/inventree
channel: ${{ github.ref_name }}
file: ${{ steps.package.outputs.package_path }}
- name: Publish to artifact
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # pin@2.11.3
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.package.outputs.package_path }}
asset_name: ${{ matrix.target }}-{{ steps.setup.outputs.version }}.tar.gz
tag: ${{ github.ref }}
overwrite: true

View File

@ -6,7 +6,7 @@ buildpack: https://github.com/matmair/null-buildpack#master
env: env:
- INVENTREE_DB_ENGINE=sqlite3 - INVENTREE_DB_ENGINE=sqlite3
- INVENTREE_DB_NAME=database.sqlite3 - INVENTREE_DB_NAME=database.sqlite3
- INVENTREE_PLUGINS_ENABLED - INVENTREE_PLUGINS_ENABLED=true
- INVENTREE_MEDIA_ROOT=/opt/inventree/media - INVENTREE_MEDIA_ROOT=/opt/inventree/media
- INVENTREE_STATIC_ROOT=/opt/inventree/static - INVENTREE_STATIC_ROOT=/opt/inventree/static
- INVENTREE_BACKUP_DIR=/opt/inventree/backup - INVENTREE_BACKUP_DIR=/opt/inventree/backup
@ -20,6 +20,7 @@ before:
- contrib/packager.io/before.sh - contrib/packager.io/before.sh
dependencies: dependencies:
- curl - curl
- poppler-utils
- "python3.11 | python3.12 | python3.13 | python3.14" - "python3.11 | python3.12 | python3.13 | python3.14"
- "python3.11-venv | python3.12-venv | python3.13-venv | python3.14-venv" - "python3.11-venv | python3.12-venv | python3.13-venv | python3.14-venv"
- "python3.11-dev | python3.12-dev | python3.13-dev | python3.14-dev" - "python3.11-dev | python3.12-dev | python3.13-dev | python3.14-dev"

View File

@ -18,7 +18,7 @@ repos:
exclude: mkdocs.yml exclude: mkdocs.yml
- id: mixed-line-ending - id: mixed-line-ending
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.10 rev: v0.15.1
hooks: hooks:
- id: ruff-format - id: ruff-format
args: [--preview] args: [--preview]
@ -29,7 +29,7 @@ repos:
--preview --preview
] ]
- repo: https://github.com/astral-sh/uv-pre-commit - repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.9.22 rev: 0.10.2
hooks: hooks:
- id: pip-compile - id: pip-compile
name: pip-compile requirements-dev.in name: pip-compile requirements-dev.in
@ -79,7 +79,7 @@ repos:
src/frontend/vite.config.ts | src/frontend/vite.config.ts |
)$ )$
- repo: https://github.com/biomejs/pre-commit - repo: https://github.com/biomejs/pre-commit
rev: v2.3.10 rev: v2.3.15
hooks: hooks:
- id: biome-check - id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.4"] additional_dependencies: ["@biomejs/biome@1.9.4"]

View File

@ -1,15 +1,15 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file (starting with 1.0.0). All major notable changes to this project will be documented in this file (starting with 1.0.0).
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased - YYYY-MM-DD ## 1.2.0 - 2026-02-12
### Breaking Changes ### Breaking Changes
- [#10699](https://github.com/inventree/InvenTree/pull/10699) removes the `PartParameter` and `PartParameterTempalate` models (and associated API endpoints). These have been replaced with generic `Parameter` and `ParameterTemplate` models (and API endpoints). Any external client applications which made use of the old endpoints will need to be updated. - [#10699](https://github.com/inventree/InvenTree/pull/10699) removes the `PartParameter` and `PartParameterTemplate` models (and associated API endpoints). These have been replaced with generic `Parameter` and `ParameterTemplate` models (and API endpoints). Any external client applications which made use of the old endpoints will need to be updated.
- [#11035](https://github.com/inventree/InvenTree/pull/11035) moves to a single endpoint for all metadata operations. The previous endpoints for PartMetadata, SupplierPartMetadata, etc have been removed. Any external client applications which made use of the old endpoints will need to be updated. - [#11035](https://github.com/inventree/InvenTree/pull/11035) moves to a single endpoint for all metadata operations. The previous endpoints for PartMetadata, SupplierPartMetadata, etc have been removed. Any external client applications which made use of the old endpoints will need to be updated.
### Added ### Added
@ -19,10 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Adds parameter support for multiple new model types in [#10699](https://github.com/inventree/InvenTree/pull/10699) - Adds parameter support for multiple new model types in [#10699](https://github.com/inventree/InvenTree/pull/10699)
- Allows report generator to produce PDF input controls in [#10969](https://github.com/inventree/InvenTree/pull/10969) - Allows report generator to produce PDF input controls in [#10969](https://github.com/inventree/InvenTree/pull/10969)
- UI overhaul of parameter management in [#10699](https://github.com/inventree/InvenTree/pull/10699) - UI overhaul of parameter management in [#10699](https://github.com/inventree/InvenTree/pull/10699)
- Allow input controls within generated PDF reports in [#10969](https://github.com/inventree/InvenTree/pull/10969)
### Changed ### Changed
- - Improved stocktake functionality in [#11257](https://github.com/inventree/InvenTree/pull/11257)
### Removed ### Removed
- Removed python 3.9 / 3.10 support as part of Django 5.2 upgrade in [#10730](https://github.com/inventree/InvenTree/pull/10730) - Removed python 3.9 / 3.10 support as part of Django 5.2 upgrade in [#10730](https://github.com/inventree/InvenTree/pull/10730)

View File

@ -9,7 +9,8 @@
# - Runs InvenTree web server under django development server # - Runs InvenTree web server under django development server
# - Monitors source files for any changes, and live-reloads server # - Monitors source files for any changes, and live-reloads server
FROM python:3.11-slim-trixie@sha256:1d6131b5d479888b43200645e03a78443c7157efbdb730e6b48129740727c312 AS inventree_base # Base image last bumped 2026-02-12
FROM python:3.11-slim-trixie@sha256:0b23cfb7425d065008b778022a17b1551c82f8b4866ee5a7a200084b7e2eafbf AS inventree_base
# Build arguments for this image # Build arguments for this image
ARG commit_tag="" ARG commit_tag=""

View File

@ -1,4 +1,4 @@
# Packages needed for CI/packages # Packages needed for CI/packages
requests==2.32.5 requests==2.33.0
pyyaml==6.0.3 pyyaml==6.0.3
jc==1.25.6 jc==1.25.6

View File

@ -214,9 +214,9 @@ pyyaml==6.0.3 \
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# -r contrib/dev_reqs/requirements.in # -r contrib/dev_reqs/requirements.in
requests==2.32.5 \ requests==2.33.0 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# -r contrib/dev_reqs/requirements.in # -r contrib/dev_reqs/requirements.in

View File

@ -16,7 +16,7 @@ Deploying InvenTree to production requires to knowledge of the security assumpti
2. All users are trusted - therefore user uploaded files can be assumed to be safe. There are basic checks in place to ensure that the files are not using common attack vectors but those are not exhaustive. 2. All users are trusted - therefore user uploaded files can be assumed to be safe. There are basic checks in place to ensure that the files are not using common attack vectors but those are not exhaustive.
3. Superuser permissions are only given to trusted users and not used for daily operations. A superuser account can manipulate or extract all files on the server that the InvenTree server process have access to. 3. Superuser or staff permissions are only given to trusted users and not used for daily operations. A superuser account can manipulate or extract all files on the server that the InvenTree server process have access to. See [dangerous user flags](../settings/permissions.md#dangerous-user-flags) for more details on user permissions and flags.
4. All templates and plugins are trusted. 4. All templates and plugins are trusted.

View File

@ -74,6 +74,9 @@ Enter the package name into the form as shown below. You can add a path and a ve
{{ image("plugin/plugin_install_txt.png", "Plugin.txt file") }} {{ image("plugin/plugin_install_txt.png", "Plugin.txt file") }}
!!! info "Superuser Required"
Only users with superuser privileges can manage plugins via the web interface.
#### Local Directory #### Local Directory
Custom plugins can be placed in the `data/plugins/` directory, where they will be automatically discovered. This can be useful for developing and testing plugins, but can prove more difficult in production (e.g. when using Docker). Custom plugins can be placed in the `data/plugins/` directory, where they will be automatically discovered. This can be useful for developing and testing plugins, but can prove more difficult in production (e.g. when using Docker).

View File

@ -50,6 +50,17 @@ Within each role, there are four levels of available permissions:
| **Add** | The *add* permission allows the user to add / create database records associated with the particular role | | **Add** | The *add* permission allows the user to add / create database records associated with the particular role |
| **Delete** | The *delete* permission allows the user to delete / remove database records associated with the particular role | | **Delete** | The *delete* permission allows the user to delete / remove database records associated with the particular role |
## Dangerous User Flags
In addition to the above permissions, there are two special flags that can be assigned to a user:
- **Staff** - A user with the *staff* flag is able to access the admin interface, and can trigger dangerous actions that might have a security impact such as changing parsable files on the server (templates / reports / plugins). Some of these actions require the *admin* role to be assigned as well.
- **Superuser** - A user with the *superuser* flag is able to access and change all data and functions of InvenTree. A superuser can modify and access all data that the InvenTree installation / server has access to - including shell access on the server OS itself. This is a very powerful flag, and should be used with caution.
It is strongly recommended to register any users with staff / superuser flags with strong MFA methods to reduce the risk of unauthorized access. These accounts should be used with caution, and should not be used for day-to-day operations.
Practicing account tiering is strongly recommended.
## Admin Interface Permissions ## Admin Interface Permissions
If a user does not have the required permissions to perform a certain action in the admin interface, those options not be displayed. If a user does not have the required permissions to perform a certain action in the admin interface, those options not be displayed.

View File

@ -401,14 +401,10 @@ It is also possible to use alternative storage backends for static and media fil
| Environment Variable | Configuration File | Description | Default | | Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| INVENTREE_S3_ACCESS_KEY | storage.s3.access_key | Access key | *Not specified* | | INVENTREE_S3_ACCESS_KEY | storage.s3.access_key | Access key | *Not specified* |
| INVENTREE_S3_SECRET_KEY | storage.s3.secret_key | Secret key | | INVENTREE_S3_SECRET_KEY | storage.s3.secret_key | Secret key | *Not specified* |
| *Not specified* | | INVENTREE_S3_BUCKET_NAME | storage.s3.bucket_name | Bucket name, required by most providers | *Not specified* |
| INVENTREE_S3_BUCKET_NAME | storage.s3.bucket_name | Bucket name, required by most providers | | INVENTREE_S3_REGION_NAME | storage.s3.region_name | S3 region name | *Not specified* |
| *Not specified* | | INVENTREE_S3_ENDPOINT_URL | storage.s3.endpoint_url | Custom S3 endpoint URL, defaults to AWS endpoints if not set | *Not specified* |
| INVENTREE_S3_REGION_NAME | storage.s3.region_name | S3 region name |
| *Not specified* |
| INVENTREE_S3_ENDPOINT_URL | storage.s3.endpoint_url | Custom S3 endpoint URL, defaults to AWS endpoints if not set |
| *Not specified* |
| INVENTREE_S3_LOCATION | storage.s3.location | Sub-Location that should be used | inventree-server | | INVENTREE_S3_LOCATION | storage.s3.location | Sub-Location that should be used | inventree-server |
| INVENTREE_S3_DEFAULT_ACL | storage.s3.default_acl | Default ACL for uploaded files, defaults to provider default if not set | *Not specified* | | INVENTREE_S3_DEFAULT_ACL | storage.s3.default_acl | Default ACL for uploaded files, defaults to provider default if not set | *Not specified* |
| INVENTREE_S3_VERIFY_SSL | storage.s3.verify_ssl | Verify SSL certificate for S3 endpoint | True | | INVENTREE_S3_VERIFY_SSL | storage.s3.verify_ssl | Verify SSL certificate for S3 endpoint | True |

View File

@ -117,7 +117,7 @@ Once the migration process completes, the database records are now updated! Rest
## Migrating Between Incompatible Database Versions ## Migrating Between Incompatible Database Versions
There may be occasions when InvenTree data needs to be migrated between two database installations running *incompatible* versions of the database software. For example, InvenTree may be running on a Postgres database running on version 12, and the administrator wishes to migrate the data to a Postgres version 17 database. There may be occasions when InvenTree data needs to be migrated between two database installations running *incompatible* versions of the database software. For example, InvenTree may be running on a Postgres database running on version 12, and the administrator wishes to migrate the data to a Postgres version {{ config.extra.docker_postgres_version }} database.
!!! warning "Advanced Procedure" !!! warning "Advanced Procedure"
The following procedure is *advanced*, and should only be attempted by experienced database administrators. Always ensure that database backups are made before attempting any migration procedure. The following procedure is *advanced*, and should only be attempted by experienced database administrators. Always ensure that database backups are made before attempting any migration procedure.
@ -127,7 +127,7 @@ Due to inherit incompatibilities between different major versions of database so
!!! warning "InvenTree Version" !!! warning "InvenTree Version"
It is *crucial* that both InvenTree database installations are running the same version of InvenTree software! If this is not the case, data migration may fail, and there is a possibility that data corruption can occur. Ensure that the original database is up to date, by running `invoke update`. It is *crucial* that both InvenTree database installations are running the same version of InvenTree software! If this is not the case, data migration may fail, and there is a possibility that data corruption can occur. Ensure that the original database is up to date, by running `invoke update`.
The following instructions assume that the source (old) database is Postgres version 15, and the target (new) database is Postgres version 17. Additionally, it assumes that the InvenTree installation is running under [docker / docker compose](./docker.md), for simplicity. Adjust commands as required for other InvenTree configurations or database software. The following instructions assume that the source (old) database is Postgres version 15, and the target (new) database is Postgres version {{ config.extra.docker_postgres_version }}. Additionally, it assumes that the InvenTree installation is running under [docker / docker compose](./docker.md), for simplicity. Adjust commands as required for other InvenTree configurations or database software.
The overall procedure is as follows: The overall procedure is as follows:
@ -154,13 +154,13 @@ docker compose down
### Remove Old Database Files ### Remove Old Database Files
The raw database files are incompatible between different major versions of Postgres. Thus, the old database files must be removed before starting the new database. The raw database files are incompatible between different major versions of Postgres. Thus, the old database files must be removed before starting the new database. Rather than removing the database directory, we will move the database files to a temporary location, just in case we need to revert back to the old database.
!!! warning "Data Loss" !!! warning "Data Loss"
Ensure that a complete backup of the old database has been made before proceeding! Removing the database files will result in data loss if a backup does not exist. Ensure that a complete backup of the old database has been made before proceeding! Removing the database files will result in data loss if a backup does not exist.
``` ```
rm -rf ./path/to/database/* mv ./path/to/database ./path/to/database_backup
``` ```
!!! info "Database Location" !!! info "Database Location"
@ -168,7 +168,7 @@ rm -rf ./path/to/database/*
### Start New Database ### Start New Database
Update the InvenTree docker configuration to use the new version of Postgres (e.g. `postgres:17`), and then start the InvenTree installation: Update the InvenTree docker configuration to use the new version of Postgres (e.g. `postgres:{{ config.extra.docker_postgres_version }}`), and then start the InvenTree installation:
``` ```
docker compose up -d docker compose up -d

View File

@ -122,7 +122,7 @@ InvenTree uses the [Redis](https://redis.io/) cache server to manage cache data.
!!! info "Redis on Docker" !!! info "Redis on Docker"
Docker adds an additional network layer - that might lead to lower performance than bare metal. Docker adds an additional network layer - that might lead to lower performance than bare metal.
To optimize and configure your redis deployment follow the [official docker guide](https://redis.io/docs/getting-started/install-stack/docker/#configuration). To optimize and configure your redis deployment follow the [official docker guide](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/docker/).
!!! tip "Enable Cache" !!! tip "Enable Cache"
While a redis container is provided in the default configuration, by default it is not enabled in the InvenTree server. You can enable redis cache support by following the [caching configuration guide](./config.md#caching) While a redis container is provided in the default configuration, by default it is not enabled in the InvenTree server. You can enable redis cache support by following the [caching configuration guide](./config.md#caching)

View File

@ -24,7 +24,7 @@ Each *Stock Item* is linked to the following information:
**Last Updated** - Date that the stock quantity was last updated **Last Updated** - Date that the stock quantity was last updated
**Last Stocktake** - Date of most recent stocktake (count) of this item **Last Stocktake** - Date that this stock item was last counted
**Status** - Status of this stock item **Status** - Status of this stock item

View File

@ -554,9 +554,9 @@ pyyaml-env-tag==1.1 \
--hash=sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04 \ --hash=sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04 \
--hash=sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff --hash=sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff
# via mkdocs # via mkdocs
requests==2.32.5 \ requests==2.33.0 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# mkdocs-macros-plugin # mkdocs-macros-plugin

View File

@ -1,2 +1,5 @@
# Files generated during unit testing # Files generated during unit testing
_testfolder/ _testfolder/
# Playwright files for CI
InvenTree/static/img/playwright*.png

View File

@ -423,23 +423,19 @@ class BulkOperationMixin:
def get_bulk_queryset(self, request): def get_bulk_queryset(self, request):
"""Return a queryset based on the selection made in the request. """Return a queryset based on the selection made in the request.
Selection can be made by providing either: Selection can be made by providing a list of primary key values,
which will be used to filter the queryset.
- items: A list of primary key values
- filters: A dictionary of filter values
""" """
model = self.serializer_class.Meta.model
items = request.data.pop('items', None) items = request.data.pop('items', None)
filters = request.data.pop('filters', None)
all_filter = request.GET.get('all', None) all_filter = request.GET.get('all', None)
queryset = model.objects.all() # Return the base queryset for this model
queryset = self.get_queryset()
if not items and not filters and all_filter is None: if not items and all_filter is None:
raise ValidationError({ raise ValidationError({
'non_field_errors': _( 'non_field_errors': _(
'List of items or filters must be provided for bulk operation' 'List of items must be provided for bulk operation'
) )
}) })
@ -457,19 +453,6 @@ class BulkOperationMixin:
'non_field_errors': _('Invalid items list provided') 'non_field_errors': _('Invalid items list provided')
}) })
if filters:
if type(filters) is not dict:
raise ValidationError({
'non_field_errors': _('Filters must be provided as a dict')
})
try:
queryset = queryset.filter(**filters)
except Exception:
raise ValidationError({
'non_field_errors': _('Invalid filters provided')
})
if all_filter and not helpers.str2bool(all_filter): if all_filter and not helpers.str2bool(all_filter):
raise ValidationError({ raise ValidationError({
'non_field_errors': _('All filter must only be used with true') 'non_field_errors': _('All filter must only be used with true')

View File

@ -331,6 +331,11 @@ class InvenTreeConfig(AppConfig):
if MIGRATIONS_CHECK_DONE: if MIGRATIONS_CHECK_DONE:
return return
# Exit early if we are not in a state where we can access the database,
# otherwise we might end up in a deadlock situation
if not InvenTree.ready.canAppAccessDatabase():
return
if not InvenTree.tasks.check_for_migrations(): if not InvenTree.tasks.check_for_migrations():
# Detect if this an empty database - if so, start with a fresh migration # Detect if this an empty database - if so, start with a fresh migration
if ( if (

View File

@ -111,9 +111,11 @@ class InvenTreeOrderingFilter(filters.OrderingFilter):
def get_ordering(self, request, queryset, view): def get_ordering(self, request, queryset, view):
"""Override ordering for supporting aliases.""" """Override ordering for supporting aliases."""
ordering = super().get_ordering(request, queryset, view) ordering = list(super().get_ordering(request, queryset, view) or [])
aliases = getattr(view, 'ordering_field_aliases', None) aliases = getattr(view, 'ordering_field_aliases', None)
lookup_field = getattr(view, 'lookup_field', 'pk')
lookup_reversed = len(ordering) > 0 and ordering[-1].startswith('-')
# Attempt to map ordering fields based on provided aliases # Attempt to map ordering fields based on provided aliases
if ordering is not None and aliases is not None: if ordering is not None and aliases is not None:
@ -123,9 +125,8 @@ class InvenTreeOrderingFilter(filters.OrderingFilter):
ordering = [] ordering = []
for field in ordering_initial: for field in ordering_initial:
reverse = field.startswith('-') field_reversed = field.startswith('-')
if field_reversed:
if reverse:
field = field[1:] field = field[1:]
# Are aliases defined for this field? # Are aliases defined for this field?
@ -153,11 +154,22 @@ class InvenTreeOrderingFilter(filters.OrderingFilter):
continue continue
for a in alias: for a in alias:
if reverse: if field_reversed:
a = '-' + a a = '-' + a
ordering.append(a) ordering.append(a)
# Ensure that any API filtering appends the primary-key field
# This is to prevent "ambiguous ordering" errors across pagination boundaries
# Ref: https://github.com/inventree/InvenTree/issues/11442
if lookup_field and not any(
field in ordering for field in [lookup_field, f'-{lookup_field}']
):
if lookup_reversed:
ordering.append(f'-{lookup_field}')
else:
ordering.append(lookup_field)
return ordering return ordering
@ -220,18 +232,10 @@ class NumericInFilter(rest_filters.BaseInFilter):
return super().filter(qs, numeric_values) return super().filter(qs, numeric_values)
SEARCH_ORDER_FILTER = [ ORDER_FILTER = [drf_backend.DjangoFilterBackend, InvenTreeOrderingFilter]
drf_backend.DjangoFilterBackend,
InvenTreeSearchFilter,
filters.OrderingFilter,
]
SEARCH_ORDER_FILTER_ALIAS = [ SEARCH_ORDER_FILTER = [
drf_backend.DjangoFilterBackend, drf_backend.DjangoFilterBackend,
InvenTreeSearchFilter, InvenTreeSearchFilter,
InvenTreeOrderingFilter, InvenTreeOrderingFilter,
] ]
ORDER_FILTER = [drf_backend.DjangoFilterBackend, filters.OrderingFilter]
ORDER_FILTER_ALIAS = [drf_backend.DjangoFilterBackend, InvenTreeOrderingFilter]

View File

@ -229,8 +229,15 @@ def getStaticUrl(filename):
return os.path.join(STATIC_URL, str(filename)) return os.path.join(STATIC_URL, str(filename))
def TestIfImage(img): def TestIfImage(img) -> bool:
"""Test if an image file is indeed an image.""" """Test if an image file is indeed an image.
Arguments:
img: A file-like object
Returns:
True if the file is a valid image, False otherwise
"""
try: try:
Image.open(img).verify() Image.open(img).verify()
return True return True
@ -250,7 +257,6 @@ def getBlankThumbnail():
def getLogoImage(as_file=False, custom=True): def getLogoImage(as_file=False, custom=True):
"""Return the InvenTree logo image, or a custom logo if available.""" """Return the InvenTree logo image, or a custom logo if available."""
"""Return the path to the logo-file."""
if custom and settings.CUSTOM_LOGO: if custom and settings.CUSTOM_LOGO:
static_storage = StaticFilesStorage() static_storage = StaticFilesStorage()

View File

@ -1,9 +1,11 @@
"""Provides helper functions used throughout the InvenTree project that access the database.""" """Provides helper functions used throughout the InvenTree project that access the database."""
import io import io
import ipaddress
import socket
from decimal import Decimal from decimal import Decimal
from typing import Optional, cast from typing import Optional, cast
from urllib.parse import urljoin from urllib.parse import urljoin, urlparse
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -88,6 +90,36 @@ def construct_absolute_url(*arg, base_url=None, request=None):
return urljoin(base_url, relative_url) return urljoin(base_url, relative_url)
def validate_url_no_ssrf(url):
"""Validate that a URL does not point to a private/internal network address.
Resolves the hostname to an IP address and checks it against private,
loopback, link-local, and reserved IP ranges to prevent SSRF attacks.
Arguments:
url: The URL to validate
Raises:
ValueError: If the URL resolves to a private or reserved IP address
"""
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise ValueError(_('Invalid URL: no hostname'))
try:
addrinfo = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise ValueError(_('Invalid URL: hostname could not be resolved'))
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError(_('URL points to a private or reserved IP address'))
def download_image_from_url(remote_url, timeout=2.5): def download_image_from_url(remote_url, timeout=2.5):
"""Download an image file from a remote URL. """Download an image file from a remote URL.
@ -115,6 +147,9 @@ def download_image_from_url(remote_url, timeout=2.5):
validator = URLValidator() validator = URLValidator()
validator(remote_url) validator(remote_url)
# SSRF protection: validate the resolved IP is not private/internal
validate_url_no_ssrf(remote_url)
# Calculate maximum allowable image size (in bytes) # Calculate maximum allowable image size (in bytes)
max_size = ( max_size = (
int(get_global_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024 int(get_global_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024
@ -129,10 +164,36 @@ def download_image_from_url(remote_url, timeout=2.5):
response = requests.get( response = requests.get(
remote_url, remote_url,
timeout=timeout, timeout=timeout,
allow_redirects=True, allow_redirects=False,
stream=True, stream=True,
headers=headers, headers=headers,
) )
# Handle redirects manually to validate each destination
max_redirects = 5
redirect_count = 0
while response.is_redirect and redirect_count < max_redirects:
redirect_url = response.headers.get('Location')
if not redirect_url:
break
# Validate the redirect destination against SSRF
validator(redirect_url)
validate_url_no_ssrf(redirect_url)
redirect_count += 1
response = requests.get(
redirect_url,
timeout=timeout,
allow_redirects=False,
stream=True,
headers=headers,
)
if redirect_count >= max_redirects:
raise ValueError(_('Too many redirects'))
# Throw an error if anything goes wrong # Throw an error if anything goes wrong
response.raise_for_status() response.raise_for_status()
except requests.exceptions.ConnectionError as exc: except requests.exceptions.ConnectionError as exc:
@ -143,6 +204,8 @@ def download_image_from_url(remote_url, timeout=2.5):
raise requests.exceptions.HTTPError( raise requests.exceptions.HTTPError(
_('Server responded with invalid status code') + f': {response.status_code}' _('Server responded with invalid status code') + f': {response.status_code}'
) )
except ValueError:
raise
except Exception as exc: except Exception as exc:
raise Exception(_('Exception occurred') + f': {exc!s}') raise Exception(_('Exception occurred') + f': {exc!s}')

View File

@ -5,7 +5,7 @@ from django.http import Http404
from django.urls import reverse from django.urls import reverse
import structlog import structlog
from rest_framework import exceptions, serializers from rest_framework import exceptions, permissions, serializers
from rest_framework.fields import empty from rest_framework.fields import empty
from rest_framework.metadata import SimpleMetadata from rest_framework.metadata import SimpleMetadata
from rest_framework.request import clone_request from rest_framework.request import clone_request
@ -131,10 +131,26 @@ class InvenTreeMetadata(SimpleMetadata):
# Remove any HTTP methods that the user does not have permission for # Remove any HTTP methods that the user does not have permission for
for method, permission in rolemap.items(): for method, permission in rolemap.items():
# general model / role permission
result = check_user_permission(user, self.model, permission) or ( result = check_user_permission(user, self.model, permission) or (
role_required and check_user_role(user, role_required, permission) role_required and check_user_role(user, role_required, permission)
) )
# check if simple IsAuthenticated permission class is used
if not result:
result = (
view.permission_classes
and len(view.permission_classes) == 1
and any(
perm
in [
permissions.IsAuthenticated,
InvenTree.permissions.IsAuthenticatedOrReadScope,
]
for perm in view.permission_classes
)
)
if method in actions and not result: if method in actions and not result:
del actions[method] del actions[method]

View File

@ -19,6 +19,11 @@ from error_report.middleware import ExceptionProcessor
from common.settings import get_global_setting from common.settings import get_global_setting
from InvenTree.cache import create_session_cache, delete_session_cache from InvenTree.cache import create_session_cache, delete_session_cache
from InvenTree.config import CONFIG_LOOKUPS, inventreeInstaller from InvenTree.config import CONFIG_LOOKUPS, inventreeInstaller
from InvenTree.version import (
inventreeApiVersion,
inventreePythonVersion,
inventreeVersion,
)
from users.models import ApiToken from users.models import ApiToken
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@ -393,3 +398,15 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
# All checks passed # All checks passed
return None return None
class InvenTreeVersionHeaderMiddleware(MiddlewareMixin):
"""Middleware to add the InvenTree version header to all responses."""
def process_response(self, request, response):
"""Add the InvenTree version header to the response."""
response['X-InvenTree-Version'] = inventreeVersion()
response['X-InvenTree-API'] = inventreeApiVersion()
response['X-InvenTree-Python'] = inventreePythonVersion()
response['X-InvenTree-Installer'] = inventreeInstaller()
return response

View File

@ -26,12 +26,7 @@ from corsheaders.defaults import default_headers as default_cors_headers
import InvenTree.backup import InvenTree.backup
from InvenTree.cache import get_cache_config, is_global_cache_enabled from InvenTree.cache import get_cache_config, is_global_cache_enabled
from InvenTree.config import ( from InvenTree.config import get_boolean_setting, get_oidc_private_key, get_setting
get_boolean_setting,
get_custom_file,
get_oidc_private_key,
get_setting,
)
from InvenTree.ready import isInMainThread from InvenTree.ready import isInMainThread
from InvenTree.sentry import default_sentry_dsn, init_sentry from InvenTree.sentry import default_sentry_dsn, init_sentry
from InvenTree.version import checkMinPythonVersion, inventreeCommitHash from InvenTree.version import checkMinPythonVersion, inventreeCommitHash
@ -203,6 +198,11 @@ PLUGINS_INSTALL_DISABLED = get_boolean_setting(
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False 'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
) )
if not PLUGINS_ENABLED:
PLUGINS_INSTALL_DISABLED = (
True # If plugins are disabled, also disable installation
)
PLUGIN_FILE = config.get_plugin_file() PLUGIN_FILE = config.get_plugin_file()
# Plugin test settings # Plugin test settings
@ -367,6 +367,7 @@ MIDDLEWARE = CONFIG.get(
'InvenTree.middleware.InvenTreeRequestCacheMiddleware', # Request caching 'InvenTree.middleware.InvenTreeRequestCacheMiddleware', # Request caching
'InvenTree.middleware.InvenTreeHostSettingsMiddleware', # Ensuring correct hosting/security settings 'InvenTree.middleware.InvenTreeHostSettingsMiddleware', # Ensuring correct hosting/security settings
'django_structlog.middlewares.RequestMiddleware', # Structured logging 'django_structlog.middlewares.RequestMiddleware', # Structured logging
'InvenTree.middleware.InvenTreeVersionHeaderMiddleware',
], ],
) )
@ -1453,12 +1454,9 @@ if len(GLOBAL_SETTINGS_OVERRIDES) > 0:
logger.debug('- Override value for %s = ********', key) logger.debug('- Override value for %s = ********', key)
# User interface customization values # User interface customization values
CUSTOM_LOGO = get_custom_file( CUSTOM_LOGO = get_setting('INVENTREE_CUSTOM_LOGO', 'customize.logo', typecast=str)
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True
) CUSTOM_SPLASH = get_setting('INVENTREE_CUSTOM_SPLASH', 'customize.splash', typecast=str)
CUSTOM_SPLASH = get_custom_file(
'INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash'
)
CUSTOMIZE = get_setting( CUSTOMIZE = get_setting(
'INVENTREE_CUSTOMIZE', 'customize', default_value=None, typecast=dict 'INVENTREE_CUSTOMIZE', 'customize', default_value=None, typecast=dict

View File

@ -587,6 +587,15 @@ def update_exchange_rates(force: bool = False):
Arguments: Arguments:
force: If True, force the update to run regardless of the last update time force: If True, force the update to run regardless of the last update time
""" """
from InvenTree.ready import canAppAccessDatabase, isRunningMigrations
if isRunningMigrations():
return
# Do not update exchange rates if we cannot access the database
if not canAppAccessDatabase(allow_test=True, allow_shell=True):
return
try: try:
from djmoney.contrib.exchange.models import Rate from djmoney.contrib.exchange.models import Rate

View File

@ -278,8 +278,7 @@ class BulkDeleteTests(InvenTreeAPITestCase):
response = self.delete(url, {}, expected_code=400) response = self.delete(url, {}, expected_code=400)
self.assertIn( self.assertIn(
'List of items or filters must be provided for bulk operation', 'List of items must be provided for bulk operation', str(response.data)
str(response.data),
) )
# DELETE with invalid 'items' # DELETE with invalid 'items'
@ -287,11 +286,6 @@ class BulkDeleteTests(InvenTreeAPITestCase):
self.assertIn('Items must be provided as a list', str(response.data)) self.assertIn('Items must be provided as a list', str(response.data))
# DELETE with invalid 'filters'
response = self.delete(url, {'filters': [1, 2, 3]}, expected_code=400)
self.assertIn('Filters must be provided as a dict', str(response.data))
class SearchTests(InvenTreeAPITestCase): class SearchTests(InvenTreeAPITestCase):
"""Unit tests for global search endpoint.""" """Unit tests for global search endpoint."""

View File

@ -17,7 +17,7 @@ from django.conf import settings
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version # InvenTree software version
INVENTREE_SW_VERSION = '1.2.0 dev' INVENTREE_SW_VERSION = '1.2.7'
# Minimum supported Python version # Minimum supported Python version
MIN_PYTHON_VERSION = (3, 11) MIN_PYTHON_VERSION = (3, 11)

View File

@ -27,7 +27,7 @@ from generic.states.api import StatusView
from InvenTree.api import BulkDeleteMixin, ParameterListMixin, meta_path from InvenTree.api import BulkDeleteMixin, ParameterListMixin, meta_path
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import ( from InvenTree.filters import (
SEARCH_ORDER_FILTER_ALIAS, SEARCH_ORDER_FILTER,
InvenTreeDateFilter, InvenTreeDateFilter,
NumberOrNullFilter, NumberOrNullFilter,
) )
@ -145,8 +145,8 @@ class BuildFilter(FilterSet):
def filter_overdue(self, queryset, name, value): def filter_overdue(self, queryset, name, value):
"""Filter the queryset to either include or exclude orders which are overdue.""" """Filter the queryset to either include or exclude orders which are overdue."""
if str2bool(value): if str2bool(value):
return queryset.filter(Build.OVERDUE_FILTER) return queryset.filter(Build.get_overdue_filter())
return queryset.exclude(Build.OVERDUE_FILTER) return queryset.exclude(Build.get_overdue_filter())
assigned_to_me = rest_filters.BooleanFilter( assigned_to_me = rest_filters.BooleanFilter(
label=_('Assigned to me'), method='filter_assigned_to_me' label=_('Assigned to me'), method='filter_assigned_to_me'
@ -343,7 +343,7 @@ class BuildList(
output_options = BuildListOutputOptions output_options = BuildListOutputOptions
filterset_class = BuildFilter filterset_class = BuildFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
ordering_fields = [ ordering_fields = [
'reference', 'reference',
'part', 'part',
@ -594,7 +594,7 @@ class BuildLineList(
"""API endpoint for accessing a list of BuildLine objects.""" """API endpoint for accessing a list of BuildLine objects."""
filterset_class = BuildLineFilter filterset_class = BuildLineFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
output_options = BuildLineOutputOptions output_options = BuildLineOutputOptions
ordering_fields = [ ordering_fields = [
'part', 'part',
@ -951,7 +951,7 @@ class BuildItemList(
output_options = BuildItemOutputOptions output_options = BuildItemOutputOptions
filterset_class = BuildItemFilter filterset_class = BuildItemFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
def get_queryset(self): def get_queryset(self):
"""Override the queryset method, to perform custom prefetch.""" """Override the queryset method, to perform custom prefetch."""

View File

@ -123,11 +123,14 @@ class Build(
order_insertion_by = ['reference'] order_insertion_by = ['reference']
OVERDUE_FILTER = ( @classmethod
Q(status__in=BuildStatusGroups.ACTIVE_CODES) def get_overdue_filter(cls):
& ~Q(target_date=None) """Filter for determining if a build order is overdue."""
& Q(target_date__lte=InvenTree.helpers.current_date()) return (
) Q(status__in=BuildStatusGroups.ACTIVE_CODES)
& ~Q(target_date=None)
& Q(target_date__lte=InvenTree.helpers.current_date())
)
# Global setting for specifying reference pattern # Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN' REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN'
@ -456,7 +459,7 @@ class Build(
bool: Is the build overdue bool: Is the build overdue
""" """
query = Build.objects.filter(pk=self.pk) query = Build.objects.filter(pk=self.pk)
query = query.filter(Build.OVERDUE_FILTER) query = query.filter(Build.get_overdue_filter())
return query.exists() return query.exists()

View File

@ -168,7 +168,8 @@ class BuildSerializer(
queryset = queryset.annotate( queryset = queryset.annotate(
overdue=Case( overdue=Case(
When( When(
Build.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()) Build.get_overdue_filter(),
then=Value(True, output_field=BooleanField()),
), ),
default=Value(False, output_field=BooleanField()), default=Value(False, output_field=BooleanField()),
) )

View File

@ -48,11 +48,7 @@ from InvenTree.api import (
meta_path, meta_path,
) )
from InvenTree.config import CONFIG_LOOKUPS from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ( from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
ORDER_FILTER,
SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS,
)
from InvenTree.helpers import inheritors, str2bool from InvenTree.helpers import inheritors, str2bool
from InvenTree.helpers_email import send_email from InvenTree.helpers_email import send_email
from InvenTree.mixins import ( from InvenTree.mixins import (
@ -918,7 +914,7 @@ class ParameterList(
"""List API endpoint for Parameter objects.""" """List API endpoint for Parameter objects."""
filterset_class = ParameterFilter filterset_class = ParameterFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['name', 'data', 'units', 'template', 'updated', 'updated_by'] ordering_fields = ['name', 'data', 'units', 'template', 'updated', 'updated_by']
@ -1005,6 +1001,22 @@ class DataOutputEndpointMixin:
serializer_class = common.serializers.DataOutputSerializer serializer_class = common.serializers.DataOutputSerializer
permission_classes = [IsAuthenticatedOrReadScope] permission_classes = [IsAuthenticatedOrReadScope]
def get_queryset(self):
"""Return the set of DataOutput objects which the user has permission to view."""
queryset = super().get_queryset()
try:
user = self.request.user
except AttributeError:
raise PermissionDenied('User information is not available')
# Allow staff users access to all DataOutput objects
if user.is_staff:
return queryset
# All other users are limited to viewing their own DataOutput objects
return queryset.filter(user=user)
class DataOutputList(DataOutputEndpointMixin, BulkDeleteMixin, ListAPI): class DataOutputList(DataOutputEndpointMixin, BulkDeleteMixin, ListAPI):
"""List view for DataOutput objects.""" """List view for DataOutput objects."""

View File

@ -19,6 +19,11 @@ logger = structlog.get_logger('inventree')
def currency_code_default(create: bool = True): def currency_code_default(create: bool = True):
"""Returns the default currency code (or USD if not specified).""" """Returns the default currency code (or USD if not specified)."""
from common.settings import get_global_setting from common.settings import get_global_setting
from InvenTree.ready import isRunningBackup, isRunningMigrations
if isRunningMigrations() or isRunningBackup():
# Prevent database writes during migration or backup operations
create = False
code = '' code = ''

View File

@ -1216,7 +1216,9 @@ class InvenTreeSetting(BaseInvenTreeSetting):
even if that key does not exist. even if that key does not exist.
""" """
SETTINGS: dict[str, InvenTreeSettingsKeyType] from common.setting.system import SYSTEM_SETTINGS
SETTINGS: dict[str, InvenTreeSettingsKeyType] = SYSTEM_SETTINGS
CHECK_SETTING_KEY = True CHECK_SETTING_KEY = True
@ -1282,9 +1284,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
The keys must be upper-case The keys must be upper-case
""" """
from common.setting.system import SYSTEM_SETTINGS
SETTINGS = SYSTEM_SETTINGS
typ = 'inventree' typ = 'inventree'
@ -1310,6 +1309,8 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
import common.setting.user import common.setting.user
SETTINGS = common.setting.user.USER_SETTINGS
CHECK_SETTING_KEY = True CHECK_SETTING_KEY = True
class Meta: class Meta:
@ -1321,8 +1322,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
models.UniqueConstraint(fields=['key', 'user'], name='unique key and user') models.UniqueConstraint(fields=['key', 'user'], name='unique key and user')
] ]
SETTINGS = common.setting.user.USER_SETTINGS
typ = 'user' typ = 'user'
extra_unique_fields = ['user'] extra_unique_fields = ['user']

View File

@ -11,7 +11,7 @@ from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from jinja2 import Template from jinja2.sandbox import SandboxedEnvironment
import build.validators import build.validators
import common.currency import common.currency
@ -49,10 +49,11 @@ def validate_part_name_format(value):
}) })
# Attempt to render the template with a dummy Part instance # Attempt to render the template with a dummy Part instance
p = Part(name='test part', description='some test part') # Use pk=1 to ensure conditional checks like {% if part.pk %} are evaluated
p = Part(pk=1, name='test part', description='some test part')
try: try:
Template(value).render({'part': p}) SandboxedEnvironment().from_string(value).render({'part': p})
except Exception as exc: except Exception as exc:
raise ValidationError({'value': str(exc)}) raise ValidationError({'value': str(exc)})

View File

@ -44,6 +44,13 @@ def get_global_setting(key, backup_value=None, environment_key=None, **kwargs):
def set_global_setting(key, value, change_user=None, create=True, **kwargs): def set_global_setting(key, value, change_user=None, create=True, **kwargs):
"""Set the value of a global setting using the provided key.""" """Set the value of a global setting using the provided key."""
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.ready import canAppAccessDatabase
if not canAppAccessDatabase(allow_shell=True, allow_test=True):
logger.warning(
f'Cannot set global setting "{key}" - database is not accessible'
)
return False
kwargs['change_user'] = change_user kwargs['change_user'] = change_user
kwargs['create'] = create kwargs['create'] = create

View File

@ -6,6 +6,44 @@ import common.models
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
class DataOutputAPITests(InvenTreeAPITestCase):
"""API tests for the DataOutput endpoint."""
roles = 'all'
def setUp(self):
"""Set up some test data for DataOutput API testing."""
from report.models import DataOutput
super().setUp()
for ii in range(5):
DataOutput.objects.create(
output_type='test_output',
user=self.user if ii % 2 == 0 else None,
complete=ii % 2 == 1,
)
def test_data_output_list(self):
"""Test the DataOutput API list endpoint."""
url = reverse('api-data-output-list')
# Non-staff user should only see outputs which are either enabled for all users, or created by themselves
self.user.is_staff = False
self.user.save()
response = self.get(url)
self.assertEqual(len(response.data), 3)
for output in response.data:
self.assertEqual(output['user'], self.user.pk)
# Set staff access = True, so we should see all outputs
self.user.is_staff = True
self.user.save()
response = self.get(url)
self.assertEqual(len(response.data), 5)
class ParameterAPITests(InvenTreeAPITestCase): class ParameterAPITests(InvenTreeAPITestCase):
"""Tests for the Parameter API.""" """Tests for the Parameter API."""

View File

@ -1317,12 +1317,18 @@ class NotificationTest(InvenTreeAPITestCase):
# Now, let's bulk delete all 'unread' notifications via the API, # Now, let's bulk delete all 'unread' notifications via the API,
# but only associated with the logged in user # but only associated with the logged in user
response = self.delete(url, {'filters': {'read': False}}, expected_code=200) read_notifications = NotificationMessage.objects.filter(read=True)
response = self.delete(
url, {'items': [ntf.pk for ntf in read_notifications]}, expected_code=200
)
# Only 7 notifications should have been deleted, # Only 3 notifications should have been deleted,
# as the notifications associated with other users must remain untouched # as the notifications associated with other users must remain untouched
self.assertEqual(NotificationMessage.objects.count(), 13) self.assertEqual(NotificationMessage.objects.count(), 17)
self.assertEqual(NotificationMessage.objects.filter(user=self.user).count(), 3) self.assertEqual(NotificationMessage.objects.filter(user=self.user).count(), 7)
self.assertEqual(
NotificationMessage.objects.filter(user=self.user, read=True).count(), 0
)
def test_simple(self): def test_simple(self):
"""Test that a simple notification can be created.""" """Test that a simple notification can be created."""

View File

@ -11,7 +11,7 @@ import part.models
from data_exporter.mixins import DataExportViewMixin from data_exporter.mixins import DataExportViewMixin
from InvenTree.api import ListCreateDestroyAPIView, ParameterListMixin, meta_path from InvenTree.api import ListCreateDestroyAPIView, ParameterListMixin, meta_path
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ( from InvenTree.mixins import (
ListCreateAPI, ListCreateAPI,
OutputOptionsMixin, OutputOptionsMixin,
@ -197,7 +197,7 @@ class ManufacturerPartList(
""" """
filterset_class = ManufacturerPartFilter filterset_class = ManufacturerPartFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
output_options = ManufacturerOutputOptions output_options = ManufacturerOutputOptions
ordering_fields = ['part', 'IPN', 'MPN', 'manufacturer'] ordering_fields = ['part', 'IPN', 'MPN', 'manufacturer']
@ -358,7 +358,7 @@ class SupplierPartList(
""" """
filterset_class = SupplierPartFilter filterset_class = SupplierPartFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
output_options = SupplierPartOutputOptions output_options = SupplierPartOutputOptions
ordering_fields = [ ordering_fields = [
@ -472,7 +472,7 @@ class SupplierPriceBreakList(
output_options = SupplierPriceBreakOutputOptions output_options = SupplierPriceBreakOutputOptions
filterset_class = SupplierPriceBreakFilter filterset_class = SupplierPriceBreakFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['quantity', 'supplier', 'SKU', 'price'] ordering_fields = ['quantity', 'supplier', 'SKU', 'price']
search_fields = ['part__SKU', 'part__supplier__name'] search_fields = ['part__SKU', 'part__supplier__name']

View File

@ -73,6 +73,22 @@ class DataImportSessionMixin:
serializer_class = importer.serializers.DataImportSessionSerializer serializer_class = importer.serializers.DataImportSessionSerializer
permission_classes = [InvenTree.permissions.DataImporterPermission] permission_classes = [InvenTree.permissions.DataImporterPermission]
def get_queryset(self):
"""Return the set of DataImportSession objects that the user has permission to view."""
queryset = super().get_queryset()
try:
user = self.request.user
except AttributeError:
raise PermissionDenied('User information is not available')
# Allow staff users access to all DataImportSession objects
if user.is_staff:
return queryset
# For non-staff users, only allow access to sessions that they have created
return queryset.filter(user=user)
class DataImportSessionList(BulkDeleteMixin, DataImportSessionMixin, ListCreateAPI): class DataImportSessionList(BulkDeleteMixin, DataImportSessionMixin, ListCreateAPI):
"""API endpoint for accessing a list of DataImportSession objects.""" """API endpoint for accessing a list of DataImportSession objects."""

View File

@ -174,6 +174,36 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
# Check that there are new database records # Check that there are new database records
self.assertEqual(PartCategory.objects.count(), N + 4) self.assertEqual(PartCategory.objects.count(), N + 4)
def test_session_list(self):
"""Test API endpoint which details the list of import sessions."""
url = reverse('api-importer-session-list')
# Construct a dummy file
f = self.helper_file('companies.csv')
for ii in range(5):
DataImportSession.objects.create(
data_file=f,
model_type='company',
user=self.user if ii % 2 == 0 else None,
)
# Staff user should see all sessions
self.user.is_staff = True
self.user.save()
response = self.get(url)
self.assertEqual(len(response.data), 5)
# Non-staff user should only see sessions which they own
self.user.is_staff = False
self.user.save()
response = self.get(url)
self.assertEqual(len(response.data), 3)
for session in response.data:
self.assertEqual(session['user'], self.user.pk)
class AdminTest(ImporterMixin, AdminTestCase): class AdminTest(ImporterMixin, AdminTestCase):
"""Tests for the admin interface integration.""" """Tests for the admin interface integration."""

View File

@ -35,11 +35,7 @@ from InvenTree.api import (
meta_path, meta_path,
) )
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import ( from InvenTree.filters import SEARCH_ORDER_FILTER, InvenTreeDateFilter
SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS,
InvenTreeDateFilter,
)
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.helpers_model import construct_absolute_url, get_base_url from InvenTree.helpers_model import construct_absolute_url, get_base_url
from InvenTree.mixins import ( from InvenTree.mixins import (
@ -391,7 +387,7 @@ class PurchaseOrderList(
""" """
filterset_class = PurchaseOrderFilter filterset_class = PurchaseOrderFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
output_options = PurchaseOrderOutputOptions output_options = PurchaseOrderOutputOptions
ordering_field_aliases = { ordering_field_aliases = {
@ -700,7 +696,7 @@ class PurchaseOrderLineItemList(
serializer.data, status=status.HTTP_201_CREATED, headers=headers serializer.data, status=status.HTTP_201_CREATED, headers=headers
) )
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
ordering_field_aliases = { ordering_field_aliases = {
'MPN': 'part__manufacturer_part__MPN', 'MPN': 'part__manufacturer_part__MPN',
@ -859,7 +855,7 @@ class SalesOrderList(
""" """
filterset_class = SalesOrderFilter filterset_class = SalesOrderFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
output_options = SalesOrderOutputOptions output_options = SalesOrderOutputOptions
ordering_field_aliases = { ordering_field_aliases = {
@ -1043,7 +1039,7 @@ class SalesOrderLineItemList(
filterset_class = SalesOrderLineItemFilter filterset_class = SalesOrderLineItemFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
output_options = SalesOrderLineItemOutputOptions output_options = SalesOrderLineItemOutputOptions
@ -1289,7 +1285,7 @@ class SalesOrderAllocationList(
"""API endpoint for listing SalesOrderAllocation objects.""" """API endpoint for listing SalesOrderAllocation objects."""
filterset_class = SalesOrderAllocationFilter filterset_class = SalesOrderAllocationFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
output_options = SalesOrderAllocationOutputOptions output_options = SalesOrderAllocationOutputOptions
ordering_fields = [ ordering_fields = [
@ -1399,7 +1395,7 @@ class SalesOrderShipmentList(SalesOrderShipmentMixin, ListCreateAPI):
"""API list endpoint for SalesOrderShipment model.""" """API list endpoint for SalesOrderShipment model."""
filterset_class = SalesOrderShipmentFilter filterset_class = SalesOrderShipmentFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['reference', 'delivery_date', 'shipment_date', 'allocated_items'] ordering_fields = ['reference', 'delivery_date', 'shipment_date', 'allocated_items']
search_fields = [ search_fields = [
@ -1528,7 +1524,7 @@ class ReturnOrderList(
"""API endpoint for accessing a list of ReturnOrder objects.""" """API endpoint for accessing a list of ReturnOrder objects."""
filterset_class = ReturnOrderFilter filterset_class = ReturnOrderFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
output_options = ReturnOrderOutputOptions output_options = ReturnOrderOutputOptions
@ -1674,7 +1670,7 @@ class ReturnOrderLineItemList(
filterset_class = ReturnOrderLineItemFilter filterset_class = ReturnOrderLineItemFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
output_options = ReturnOrderLineItemOutputOptions output_options = ReturnOrderLineItemOutputOptions

View File

@ -1863,12 +1863,14 @@ class PurchaseOrderLineItem(OrderLineItem):
verbose_name = _('Purchase Order Line Item') verbose_name = _('Purchase Order Line Item')
# Filter for determining if a particular PurchaseOrderLineItem is overdue @classmethod
OVERDUE_FILTER = ( def get_overdue_filter(cls):
Q(received__lt=F('quantity')) """Filter for determining if a particular PurchaseOrderLineItem is overdue."""
& ~Q(target_date=None) return (
& Q(target_date__lt=InvenTree.helpers.current_date()) Q(received__lt=F('quantity'))
) & ~Q(target_date=None)
& Q(target_date__lt=InvenTree.helpers.current_date())
)
@staticmethod @staticmethod
def get_api_url() -> str: def get_api_url() -> str:
@ -2067,12 +2069,14 @@ class SalesOrderLineItem(OrderLineItem):
verbose_name = _('Sales Order Line Item') verbose_name = _('Sales Order Line Item')
# Filter for determining if a particular SalesOrderLineItem is overdue @classmethod
OVERDUE_FILTER = ( def get_overdue_filter(cls):
Q(shipped__lt=F('quantity')) """Filter for determining if a particular SalesOrderLineItem is overdue."""
& ~Q(target_date=None) return (
& Q(target_date__lt=InvenTree.helpers.current_date()) Q(shipped__lt=F('quantity'))
) & ~Q(target_date=None)
& Q(target_date__lt=InvenTree.helpers.current_date())
)
@staticmethod @staticmethod
def get_api_url(): def get_api_url():

View File

@ -577,7 +577,7 @@ class PurchaseOrderLineItemSerializer(
queryset = queryset.annotate( queryset = queryset.annotate(
overdue=Case( overdue=Case(
When( When(
order.models.PurchaseOrderLineItem.OVERDUE_FILTER, order.models.PurchaseOrderLineItem.get_overdue_filter(),
then=Value(True, output_field=BooleanField()), then=Value(True, output_field=BooleanField()),
), ),
default=Value(False, output_field=BooleanField()), default=Value(False, output_field=BooleanField()),
@ -1154,7 +1154,7 @@ class SalesOrderLineItemSerializer(
overdue=Case( overdue=Case(
When( When(
Q(order__status__in=SalesOrderStatusGroups.OPEN) Q(order__status__in=SalesOrderStatusGroups.OPEN)
& order.models.SalesOrderLineItem.OVERDUE_FILTER, & order.models.SalesOrderLineItem.get_overdue_filter(),
then=Value(True, output_field=BooleanField()), then=Value(True, output_field=BooleanField()),
), ),
default=Value(False, output_field=BooleanField()), default=Value(False, output_field=BooleanField()),

View File

@ -24,9 +24,7 @@ from InvenTree.api import (
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import ( from InvenTree.filters import (
ORDER_FILTER, ORDER_FILTER,
ORDER_FILTER_ALIAS,
SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS,
InvenTreeDateFilter, InvenTreeDateFilter,
InvenTreeSearchFilter, InvenTreeSearchFilter,
NumberOrNullFilter, NumberOrNullFilter,
@ -75,20 +73,6 @@ class CategoryMixin:
queryset = part_serializers.CategorySerializer.annotate_queryset(queryset) queryset = part_serializers.CategorySerializer.annotate_queryset(queryset)
return queryset return queryset
def get_serializer_context(self):
"""Add extra context to the serializer for the CategoryDetail endpoint."""
ctx = super().get_serializer_context()
try:
ctx['starred_categories'] = [
star.category for star in self.request.user.starred_categories.all()
]
except AttributeError:
# Error is thrown if the view does not have an associated request
ctx['starred_categories'] = []
return ctx
class CategoryFilter(FilterSet): class CategoryFilter(FilterSet):
"""Custom filterset class for the PartCategoryList endpoint.""" """Custom filterset class for the PartCategoryList endpoint."""
@ -162,7 +146,7 @@ class CategoryFilter(FilterSet):
Note: If the "parent" filter is provided, we offload the logic to that method. Note: If the "parent" filter is provided, we offload the logic to that method.
""" """
parent = str2bool(self.data.get('parent', None)) parent = self.data.get('parent', None)
top_level = str2bool(self.data.get('top_level', None)) top_level = str2bool(self.data.get('top_level', None))
# If the parent is *not* provided, update the results based on the "cascade" value # If the parent is *not* provided, update the results based on the "cascade" value
@ -268,14 +252,13 @@ class CategoryDetail(CategoryMixin, OutputOptionsMixin, CustomRetrieveUpdateDest
"""Perform 'update' function and mark this part as 'starred' (or not).""" """Perform 'update' function and mark this part as 'starred' (or not)."""
# Clean up input data # Clean up input data
data = self.clean_data(request.data) data = self.clean_data(request.data)
response = super().update(request, *args, **kwargs)
if 'starred' in data: if 'starred' in data:
starred = str2bool(data.get('starred', False)) starred = str2bool(data.get('starred', False))
self.get_object().set_starred(request.user, starred, include_parents=False) self.get_object().set_starred(request.user, starred, include_parents=False)
response = super().update(request, *args, **kwargs)
return response return response
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
@ -302,7 +285,7 @@ class CategoryTree(ListAPI):
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategoryTree serializer_class = part_serializers.CategoryTree
filter_backends = ORDER_FILTER_ALIAS filter_backends = ORDER_FILTER
ordering_fields = ['level', 'name', 'subcategories'] ordering_fields = ['level', 'name', 'subcategories']
@ -1029,27 +1012,8 @@ class PartMixin(SerializerContextMixin):
# Indicate that we can create a new Part via this endpoint # Indicate that we can create a new Part via this endpoint
kwargs['create'] = self.is_create kwargs['create'] = self.is_create
# Pass a list of "starred" parts to the current user to the serializer
# We do this to reduce the number of database queries required!
if (
self.starred_parts is None
and self.request is not None
and hasattr(self.request.user, 'starred_parts')
):
self.starred_parts = [
star.part for star in self.request.user.starred_parts.all()
]
kwargs['starred_parts'] = self.starred_parts
return super().get_serializer(*args, **kwargs) return super().get_serializer(*args, **kwargs)
def get_serializer_context(self):
"""Extend serializer context data."""
context = super().get_serializer_context()
context['request'] = self.request
return context
class PartOutputOptions(OutputConfiguration): class PartOutputOptions(OutputConfiguration):
"""Output options for Part endpoints.""" """Output options for Part endpoints."""
@ -1080,7 +1044,7 @@ class PartList(
filterset_class = PartFilter filterset_class = PartFilter
is_create = True is_create = True
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
ordering_fields = [ ordering_fields = [
'name', 'name',
@ -1134,6 +1098,7 @@ class PartDetail(PartMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
""" """
# Clean input data # Clean input data
data = self.clean_data(request.data) data = self.clean_data(request.data)
response = super().update(request, *args, **kwargs)
if 'starred' in data: if 'starred' in data:
starred = str2bool(data.get('starred', False)) starred = str2bool(data.get('starred', False))
@ -1142,8 +1107,6 @@ class PartDetail(PartMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
request.user, starred, include_variants=False, include_categories=False request.user, starred, include_variants=False, include_categories=False
) )
response = super().update(request, *args, **kwargs)
return response return response
@ -1442,7 +1405,7 @@ class BomList(
output_options = BomOutputOptions output_options = BomOutputOptions
filterset_class = BomFilter filterset_class = BomFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
search_fields = [ search_fields = [
'reference', 'reference',

View File

@ -5,7 +5,7 @@ import os
from django.conf import settings from django.conf import settings
import structlog import structlog
from jinja2 import Environment, select_autoescape from jinja2.sandbox import SandboxedEnvironment
from common.settings import get_global_setting from common.settings import get_global_setting
@ -37,11 +37,7 @@ def compile_full_name_template(*args, **kwargs):
# Cache the template string # Cache the template string
_part_full_name_template_string = template_string _part_full_name_template_string = template_string
env = Environment( env = SandboxedEnvironment(variable_start_string='{{', variable_end_string='}}')
autoescape=select_autoescape(default_for_string=False, default=False),
variable_start_string='{{',
variable_end_string='}}',
)
# Compile the template # Compile the template
try: try:

View File

@ -129,7 +129,16 @@ class CategorySerializer(
def get_starred(self, category) -> bool: def get_starred(self, category) -> bool:
"""Return True if the category is directly "starred" by the current user.""" """Return True if the category is directly "starred" by the current user."""
return category in self.context.get('starred_categories', []) if not self.request or not self.request.user:
return False
# Cache the "starred_categories" list for the current user
if not hasattr(self, 'starred_categories'):
self.starred_categories = [
star.category.pk for star in self.request.user.starred_categories.all()
]
return category.pk in self.starred_categories
path = enable_filter( path = enable_filter(
FilterableListField( FilterableListField(
@ -638,7 +647,6 @@ class PartSerializer(
- Allows us to optionally pass extra fields based on the query. - Allows us to optionally pass extra fields based on the query.
""" """
self.starred_parts = kwargs.pop('starred_parts', [])
create = kwargs.pop('create', False) create = kwargs.pop('create', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -754,7 +762,16 @@ class PartSerializer(
def get_starred(self, part) -> bool: def get_starred(self, part) -> bool:
"""Return "true" if the part is starred by the current user.""" """Return "true" if the part is starred by the current user."""
return part in self.starred_parts if not self.request or not self.request.user:
return False
# Cache the "starred_parts" list for the current user
if not hasattr(self, 'starred_parts'):
self.starred_parts = [
star.part.pk for star in self.request.user.starred_parts.all()
]
return part.pk in self.starred_parts
# Extra detail for the category # Extra detail for the category
category_detail = enable_filter( category_detail = enable_filter(

View File

@ -74,7 +74,6 @@ class PartImageTestMixin:
{'image': img_file}, {'image': img_file},
expected_code=200, expected_code=200,
) )
print(response.data)
image_name = response.data['image'] image_name = response.data['image']
self.assertTrue(image_name.startswith('/media/part_images/part_image')) self.assertTrue(image_name.startswith('/media/part_images/part_image'))
return image_name return image_name
@ -111,7 +110,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
url = reverse('api-part-category-list') url = reverse('api-part-category-list')
# star categories manually for tests as it is not possible with fixures # star categories manually for tests as it is not possible with fixures
# because the current user is not fixured itself and throws an invalid # because the current user is not fixtured itself and throws an invalid
# foreign key constraint # foreign key constraint
for pk in [3, 4]: for pk in [3, 4]:
PartCategory.objects.get(pk=pk).set_starred(self.user, True) PartCategory.objects.get(pk=pk).set_starred(self.user, True)
@ -812,9 +811,14 @@ class PartAPITest(PartAPITestBase):
# Children of PartCategory<1>, do not cascade # Children of PartCategory<1>, do not cascade
response = self.get(url, {'parent': 1, 'cascade': 'false'}) response = self.get(url, {'parent': 1, 'cascade': 'false'})
self.assertEqual(len(response.data), 3) self.assertEqual(len(response.data), 3)
# Children of PartCategory<7>, with or without cascade
# Only 1 child in either case
for cascade in ['true', 'false']:
response = self.get(url, {'parent': 7, 'cascade': cascade})
self.assertEqual(len(response.data), 1)
def test_add_categories(self): def test_add_categories(self):
"""Check that we can add categories.""" """Check that we can add categories."""
data = {'name': 'Animals', 'description': 'All animals go here'} data = {'name': 'Animals', 'description': 'All animals go here'}
@ -1644,7 +1648,7 @@ class PartCreationTests(PartAPITestBase):
self.assertEqual(cat.parameter_templates.count(), 3) self.assertEqual(cat.parameter_templates.count(), 3)
# Creat a new Part, without copying category parameters # Create a new Part, without copying category parameters
data = self.post( data = self.post(
reverse('api-part-list'), reverse('api-part-list'),
{ {
@ -1833,7 +1837,7 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
# Part should not have an image! # Part should not have an image!
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
print(p.image.file) _x = p.image.file
# Try to upload a non-image file # Try to upload a non-image file
test_path = get_testfolder_dir() / 'dummy_image' test_path = get_testfolder_dir() / 'dummy_image'
@ -2314,7 +2318,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
self.assertEqual(data['allocated_to_build_orders'], 0) self.assertEqual(data['allocated_to_build_orders'], 0)
self.assertEqual(data['allocated_to_sales_orders'], 0) self.assertEqual(data['allocated_to_sales_orders'], 0)
# The unallocated stock count should equal the 'in stock' coutn # The unallocated stock count should equal the 'in stock' count
in_stock = data['in_stock'] in_stock = data['in_stock']
self.assertEqual(in_stock, 126) self.assertEqual(in_stock, 126)
self.assertEqual(data['unallocated_stock'], in_stock) self.assertEqual(data['unallocated_stock'], in_stock)

View File

@ -210,6 +210,7 @@ class PluginInstall(CreateAPI):
queryset = PluginConfig.objects.none() queryset = PluginConfig.objects.none()
serializer_class = PluginSerializers.PluginConfigInstallSerializer serializer_class = PluginSerializers.PluginConfigInstallSerializer
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
"""Install a plugin via the API.""" """Install a plugin via the API."""

View File

@ -113,9 +113,14 @@ class AppMixin:
break break
# unregister the models (yes, models are just kept in multilevel dicts) # unregister the models (yes, models are just kept in multilevel dicts)
# Note: Django registers models under the app_label (app_name),
# not the full dotted plugin_path. For plugins with nested module
# paths (e.g. "myplugin.myplugin"), plugin_path != app_name, so
# using plugin_path here would look up the wrong key in the
# defaultdict and raise KeyError on .pop().
for model in models: for model in models:
# remove model from general registry # remove model from general registry
apps.all_models[plugin_path].pop(model) apps.all_models[app_name].pop(model, None)
# clear the registry for that app # clear the registry for that app
# so that the import trick will work on reloading the same plugin # so that the import trick will work on reloading the same plugin

View File

@ -1,5 +1,8 @@
"""Unit tests for base mixins for plugins.""" """Unit tests for base mixins for plugins."""
from collections import OrderedDict, defaultdict
from unittest.mock import MagicMock, patch
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.urls import include, path, re_path from django.urls import include, path, re_path
@ -145,6 +148,56 @@ class AppMixinTest(BaseMixinDefinition, TestCase):
"""Test that the sample plugin registers in settings.""" """Test that the sample plugin registers in settings."""
self.assertIn('plugin.samples.integration', settings.INSTALLED_APPS) self.assertIn('plugin.samples.integration', settings.INSTALLED_APPS)
def test_deactivate_nested_plugin_path(self):
"""Test that _deactivate_mixin uses app_name (not plugin_path) for model deregistration.
External plugins with nested module paths (e.g. "purchase_quotation.purchase_quotation")
have plugin_path != app_name. Django registers models under app_name (the last component),
so using the full plugin_path to look up apps.all_models would access the wrong key
in the defaultdict and raise KeyError on .pop().
"""
# Simulate a nested plugin path like an external plugin installed via:
# entry_points={"inventree_plugins": [
# "PurchaseQuotationPlugin = purchase_quotation.plugin:PurchaseQuotationPlugin"
# ]}
# where the package structure is purchase_quotation/purchase_quotation/
nested_plugin_path = 'purchase_quotation.purchase_quotation'
app_name = 'purchase_quotation' # Django registers models under this
# Set up a mock registry with the nested plugin path
mock_registry = MagicMock()
mock_registry.installed_apps = [nested_plugin_path]
# Create a fake model
mock_model = MagicMock()
mock_model._meta.model_name = 'purchasequotation'
# Create a mock app_config that returns our fake model
mock_app_config = MagicMock()
mock_app_config.get_models.return_value = [mock_model]
# Set up apps.all_models with the model registered under app_name
# (this is how Django actually stores it)
fake_all_models = defaultdict(OrderedDict)
fake_all_models[app_name]['purchasequotation'] = mock_model
with (
patch(
'plugin.base.integration.AppMixin.apps.get_app_config',
return_value=mock_app_config,
),
patch('plugin.base.integration.AppMixin.apps.all_models', fake_all_models),
):
# This should NOT raise KeyError - the fix ensures we use
# app_name (the last path component) instead of the full plugin_path
AppMixin._deactivate_mixin(mock_registry, force_reload=False)
# Verify the model was removed from the registry
self.assertNotIn('purchasequotation', fake_all_models.get(app_name, {}))
# Verify the full nested path was NOT used as a key
# (defaultdict would have created an empty entry if accessed)
self.assertNotIn(nested_plugin_path, fake_all_models)
class NavigationMixinTest(BaseMixinDefinition, TestCase): class NavigationMixinTest(BaseMixinDefinition, TestCase):
"""Tests for NavigationMixin.""" """Tests for NavigationMixin."""

View File

@ -236,8 +236,8 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
user: Optional user performing the installation user: Optional user performing the installation
version: Optional version specifier version: Optional version specifier
""" """
if user and not user.is_staff: if user and not user.is_superuser:
raise ValidationError(_('Only staff users can administer plugins')) raise ValidationError(_('Only superuser accounts can administer plugins'))
if settings.PLUGINS_INSTALL_DISABLED: if settings.PLUGINS_INSTALL_DISABLED:
raise ValidationError(_('Plugin installation is disabled')) raise ValidationError(_('Plugin installation is disabled'))
@ -269,6 +269,13 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
if version: if version:
full_pkg = f'{full_pkg}=={version}' full_pkg = f'{full_pkg}=={version}'
if not full_pkg:
raise ValidationError(_('No package name or URL provided for installation'))
# Sanitize the package name for installation
if any(c in full_pkg for c in ';&|`$()'):
raise ValidationError(_('Invalid characters in package name or URL'))
install_name.append(full_pkg) install_name.append(full_pkg)
ret = {} ret = {}
@ -333,6 +340,9 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T
""" """
from plugin.registry import registry from plugin.registry import registry
if user and not user.is_superuser:
raise ValidationError(_('Only superuser accounts can administer plugins'))
if settings.PLUGINS_INSTALL_DISABLED: if settings.PLUGINS_INSTALL_DISABLED:
raise ValidationError(_('Plugin uninstalling is disabled')) raise ValidationError(_('Plugin uninstalling is disabled'))

View File

@ -473,12 +473,19 @@ class PluginsRegistry:
# Ensure that each loaded plugin has a valid configuration object in the database # Ensure that each loaded plugin has a valid configuration object in the database
for plugin in self.plugins.values(): for plugin in self.plugins.values():
config = self.get_plugin_config(plugin.slug) if config := self.get_plugin_config(plugin.slug):
# Ensure mandatory plugins are marked as active
if config.is_mandatory() and not config.active:
config.active = True
# Ensure mandatory plugins are marked as active try:
if config.is_mandatory() and not config.active: config.save(no_reload=True)
config.active = True except (OperationalError, ProgrammingError):
config.save(no_reload=True) # Database is not ready, cannot save config
logger.warning(
"Database not ready - cannot set mandatory flag for plugin '%s'",
plugin.slug,
)
except Exception as e: except Exception as e:
logger.exception('Unexpected error during plugin reload: %s', e) logger.exception('Unexpected error during plugin reload: %s', e)

View File

@ -68,7 +68,7 @@ class SampleValidatorPlugin(SettingsMixin, ValidationMixin, InvenTreePlugin):
if isinstance(instance, part.models.BomItem): if isinstance(instance, part.models.BomItem):
if self.get_setting('BOM_ITEM_INTEGER'): if self.get_setting('BOM_ITEM_INTEGER'):
if float(instance.quantity) != int(instance.quantity): if float(instance.quantity) != int(instance.quantity): # noqa: RUF069
self.raise_error({ self.raise_error({
'quantity': 'Bom item quantity must be an integer' 'quantity': 'Bom item quantity must be an integer'
}) })

View File

@ -69,8 +69,11 @@ class SampleSupplierTest(InvenTreeAPITestCase):
self.assertEqual(len(res.data), 15) self.assertEqual(len(res.data), 15)
self.assertEqual(res.data[0]['sku'], 'BOLT-Steel-M5-5') self.assertEqual(res.data[0]['sku'], 'BOLT-Steel-M5-5')
def test_import_part(self): def _disabled_test_import_part(self):
"""Test importing a part by supplier.""" """Test importing a part by supplier.
Note: This test is disabled for the 1.2.x branch, as a fix has not been back-ported for the broken test
"""
# Activate plugin # Activate plugin
plugin = registry.get_plugin('samplesupplier', active=None) plugin = registry.get_plugin('samplesupplier', active=None)
config = plugin.plugin_config() config = plugin.plugin_config()

View File

@ -165,6 +165,9 @@ class PluginConfigInstallSerializer(serializers.Serializer):
version = data.get('version', None) version = data.get('version', None)
user = self.context['request'].user user = self.context['request'].user
if not user or not user.is_superuser:
raise ValidationError(_('Only superuser accounts can administer plugins'))
return install_plugin( return install_plugin(
url=url, packagename=packagename, version=version, user=user url=url, packagename=packagename, version=version, user=user
) )
@ -266,10 +269,13 @@ class PluginUninstallSerializer(serializers.Serializer):
"""Uninstall the specified plugin.""" """Uninstall the specified plugin."""
from plugin.installer import uninstall_plugin from plugin.installer import uninstall_plugin
user = self.context['request'].user
if not user or not user.is_superuser:
raise ValidationError(_('Only superuser accounts can administer plugins'))
return uninstall_plugin( return uninstall_plugin(
instance, instance, user=user, delete_config=validated_data.get('delete_config', True)
user=self.context['request'].user,
delete_config=validated_data.get('delete_config', True),
) )

View File

@ -63,6 +63,21 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
"""Test the plugin install command.""" """Test the plugin install command."""
url = reverse('api-plugin-install') url = reverse('api-plugin-install')
# Requires superuser permissions
self.user.is_superuser = False
self.user.save()
self.post(
url,
{'confirm': True, 'packagename': self.PKG_NAME},
expected_code=403,
max_query_time=30,
)
# Provide superuser permissions
self.user.is_superuser = True
self.user.save()
# invalid package name # invalid package name
data = self.post( data = self.post(
url, url,
@ -209,7 +224,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
test_plg.refresh_from_db() test_plg.refresh_from_db()
self.assertTrue(test_plg.is_active()) self.assertTrue(test_plg.is_active())
def test_pluginCfg_delete(self): def test_plugin_config_delete(self):
"""Test deleting a config.""" """Test deleting a config."""
test_plg = self.plugin_confs.first() test_plg = self.plugin_confs.first()
assert test_plg is not None assert test_plg is not None

View File

@ -11,11 +11,14 @@ from typing import Optional
from unittest import mock from unittest import mock
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
import plugin.templatetags.plugin_extras as plugin_tags import plugin.templatetags.plugin_extras as plugin_tags
from InvenTree.unit_test import PluginRegistryMixin, TestQueryMixin from InvenTree.unit_test import PluginRegistryMixin, TestQueryMixin
from plugin import InvenTreePlugin, PluginMixinEnum from plugin import InvenTreePlugin, PluginMixinEnum
from plugin.installer import install_plugin
from plugin.registry import registry from plugin.registry import registry
from plugin.samples.integration.another_sample import ( from plugin.samples.integration.another_sample import (
NoIntegrationPlugin, NoIntegrationPlugin,
@ -278,6 +281,9 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase):
def test_broken_samples(self): def test_broken_samples(self):
"""Test that the broken samples trigger reloads.""" """Test that the broken samples trigger reloads."""
# Reset the registry to a known state
registry.errors = {}
# In the base setup there are no errors # In the base setup there are no errors
self.assertEqual(len(registry.errors), 0) self.assertEqual(len(registry.errors), 0)
@ -638,3 +644,40 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase):
self.assertTrue(cfg.is_builtin()) self.assertTrue(cfg.is_builtin())
self.assertFalse(cfg.is_package()) self.assertFalse(cfg.is_package())
self.assertFalse(cfg.is_sample()) self.assertFalse(cfg.is_sample())
class InstallerTests(TestCase):
"""Tests for the plugin installer code."""
def test_plugin_install_errors(self):
"""Test error handling for plugin installation."""
# No data provided
with self.assertRaises(ValidationError) as e:
install_plugin()
self.assertIn(
'No package name or URL provided for installation', str(e.exception)
)
# Invalid package name
for pkg in [
'invalid;name',
'invalid&name',
'invalid|name',
'invalid`name',
'invalid$(name)',
]:
with self.assertRaises(ValidationError) as e:
install_plugin(packagename=pkg)
self.assertIn('Invalid characters in package name or URL', str(e.exception))
# Non superuser account
user = User.objects.create(username='my-user', is_superuser=False)
with self.assertRaises(ValidationError) as e:
install_plugin(user=user, packagename='some-package')
self.assertIn(
'Only superuser accounts can administer plugins', str(e.exception)
)

View File

@ -7,6 +7,7 @@ from django.apps import AppConfig
from django.core.exceptions import AppRegistryNotReady, ValidationError from django.core.exceptions import AppRegistryNotReady, ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.db import transaction
from django.db.utils import IntegrityError, OperationalError, ProgrammingError from django.db.utils import IntegrityError, OperationalError, ProgrammingError
import structlog import structlog
@ -160,9 +161,10 @@ class ReportConfig(AppConfig):
# Otherwise, create a new entry # Otherwise, create a new entry
try: try:
# Create a new entry # Create a new entry
report.models.LabelTemplate.objects.create( with transaction.atomic():
**template, template=self.file_from_template('label', filename) report.models.LabelTemplate.objects.create(
) **template, template=self.file_from_template('label', filename)
)
logger.info("Creating new label template: '%s'", template['name']) logger.info("Creating new label template: '%s'", template['name'])
except ValidationError: except ValidationError:
logger.warning( logger.warning(

View File

@ -5,12 +5,16 @@ import logging
import os import os
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from django import template from django import template
from django.apps.registry import apps from django.apps.registry import apps
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.exceptions import SuspiciousFileOperation, ValidationError
from django.core.files.storage import default_storage
from django.db.models import Model from django.db.models import Model
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
@ -145,6 +149,111 @@ def getkey(container: dict, key: str, backup_value: Optional[Any] = None) -> Any
return container.get(key, backup_value) return container.get(key, backup_value)
def media_file_exists(path: Path | str) -> bool:
"""Check if a media file exists at the specified path.
Arguments:
path: The path to the media file, relative to the media storage root
Returns:
True if the file exists, False otherwise
"""
if not path:
return False
try:
return default_storage.exists(str(path))
except SuspiciousFileOperation:
# Prevent path traversal attacks
raise ValidationError(_('Invalid media file path') + f": '{path}'")
def static_file_exists(path: Path | str) -> bool:
"""Check if a static file exists at the specified path.
Arguments:
path: The path to the static file, relative to the static storage root
Returns:
True if the file exists, False otherwise
"""
if not path:
return False
try:
return staticfiles_storage.exists(str(path))
except SuspiciousFileOperation:
# Prevent path traversal attacks
raise ValidationError(_('Invalid static file path') + f": '{path}'")
def get_static_file_contents(
path: Path | str, raise_error: bool = True
) -> bytes | None:
"""Return the contents of a static file.
Arguments:
path: The path to the static file, relative to the static storage root
raise_error: If True, raise an error if the file cannot be found (default = True)
Returns:
The contents of the static file, or None if the file cannot be found
"""
if not path:
if raise_error:
raise ValueError('No media file specified')
else:
return None
if not staticfiles_storage.exists(path):
if raise_error:
raise FileNotFoundError(f'Static file does not exist: {path!s}')
else:
return None
with staticfiles_storage.open(str(path)) as f:
file_data = f.read()
return file_data
def get_media_file_contents(path: Path | str, raise_error: bool = True) -> bytes | None:
"""Return the fully qualified file path to an uploaded media file.
Arguments:
path: The path to the media file, relative to the media storage root
raise_error: If True, raise an error if the file cannot be found (default = True)
Returns:
The contents of the media file, or None if the file cannot be found
Raises:
FileNotFoundError: If the requested media file cannot be loaded
PermissionError: If the requested media file is outside of the media root
ValidationError: If the provided path is invalid
Notes:
- The resulting path is resolved against the media root directory
"""
if not path:
if raise_error:
raise ValueError('No media file specified')
else:
return None
if not media_file_exists(path):
if raise_error:
raise FileNotFoundError(f'Media file does not exist: {path!s}')
else:
return None
# Load the file - and return the contents
with default_storage.open(str(path)) as f:
file_data = f.read()
return file_data
@register.simple_tag() @register.simple_tag()
def asset(filename): def asset(filename):
"""Return fully-qualified path for an upload report asset file. """Return fully-qualified path for an upload report asset file.
@ -159,18 +268,21 @@ def asset(filename):
# Prepend an empty string to enforce 'stringiness' # Prepend an empty string to enforce 'stringiness'
filename = '' + filename filename = '' + filename
# If in debug mode, return URL to the image, not a local file # Remove any leading slash characters from the filename, to prevent path traversal attacks
debug_mode = get_global_setting('REPORT_DEBUG_MODE', cache=False) filename = str(filename).lstrip('/\\')
# Test if the file actually exists full_path = Path('report', 'assets', filename)
full_path = settings.MEDIA_ROOT.joinpath('report', 'assets', filename).resolve()
if not full_path.exists() or not full_path.is_file(): if not media_file_exists(full_path):
raise FileNotFoundError(_('Asset file does not exist') + f": '{filename}'") raise FileNotFoundError(_('Asset file not found') + f": '{filename}'")
if debug_mode: # In debug mode, return a web URL to the asset file (rather than a local file path)
return os.path.join(settings.MEDIA_URL, 'report', 'assets', filename) if get_global_setting('REPORT_DEBUG_MODE', cache=False):
return f'file://{full_path}' return str(Path(settings.MEDIA_URL, 'report', 'assets', filename))
storage_path = default_storage.path(str(full_path))
return f'file://{storage_path}'
@register.simple_tag() @register.simple_tag()
@ -187,7 +299,7 @@ def uploaded_image(
"""Return raw image data from an 'uploaded' image. """Return raw image data from an 'uploaded' image.
Arguments: Arguments:
filename: The filename of the image relative to the MEDIA_ROOT directory filename: The filename of the image relative to the media root directory
replace_missing: Optionally return a placeholder image if the provided filename does not exist (default = True) replace_missing: Optionally return a placeholder image if the provided filename does not exist (default = True)
replacement_file: The filename of the placeholder image (default = 'blank_image.png') replacement_file: The filename of the placeholder image (default = 'blank_image.png')
validate: Optionally validate that the file is a valid image file validate: Optionally validate that the file is a valid image file
@ -205,38 +317,43 @@ def uploaded_image(
# Prepend an empty string to enforce 'stringiness' # Prepend an empty string to enforce 'stringiness'
filename = '' + filename filename = '' + filename
# Strip out any leading slash characters from the filename, to prevent path traversal attacks
filename = str(filename).lstrip('/\\')
# If in debug mode, return URL to the image, not a local file # If in debug mode, return URL to the image, not a local file
debug_mode = get_global_setting('REPORT_DEBUG_MODE', cache=False) debug_mode = get_global_setting('REPORT_DEBUG_MODE', cache=False)
# Check if the file exists # Load image data - this will check if the file exists
if not filename: exists = bool(filename) and media_file_exists(filename)
exists = False
else:
try:
full_path = settings.MEDIA_ROOT.joinpath(filename).resolve()
exists = full_path.exists() and full_path.is_file()
except Exception: # pragma: no cover
exists = False # pragma: no cover
if exists and validate and not InvenTree.helpers.TestIfImage(full_path):
logger.warning("File '%s' is not a valid image", filename)
exists = False
if not exists and not replace_missing: if not exists and not replace_missing:
raise FileNotFoundError(_('Image file not found') + f": '{filename}'") raise FileNotFoundError(_('Image file not found') + f": '{filename}'")
if exists:
img_data = get_media_file_contents(filename, raise_error=False)
# Check if the image data is valid
if (
img_data
and validate
and not InvenTree.helpers.TestIfImage(BytesIO(img_data))
):
logger.warning("File '%s' is not a valid image", filename)
img_data = None
exists = False
else:
# Load the backup image from the static files directory
replacement_file_path = Path('img', replacement_file)
img_data = get_static_file_contents(replacement_file_path)
if debug_mode: if debug_mode:
# In debug mode, return a web path (rather than an encoded image blob) # In debug mode, return a web path (rather than an encoded image blob)
if exists: if exists:
return os.path.join(settings.MEDIA_URL, filename) return os.path.join(settings.MEDIA_URL, filename)
return os.path.join(settings.STATIC_URL, 'img', replacement_file) return os.path.join(settings.STATIC_URL, 'img', replacement_file)
elif not exists: if img_data:
full_path = settings.STATIC_ROOT.joinpath('img', replacement_file).resolve() img = Image.open(BytesIO(img_data))
# Load the image, check that it is valid
if full_path.exists() and full_path.is_file():
img = Image.open(full_path)
else: else:
# A placeholder image showing that the image is missing # A placeholder image showing that the image is missing
img = Image.new('RGB', (64, 64), color='red') img = Image.new('RGB', (64, 64), color='red')
@ -288,22 +405,15 @@ def encode_svg_image(filename: str) -> str:
# Prepend an empty string to enforce 'stringiness' # Prepend an empty string to enforce 'stringiness'
filename = '' + filename filename = '' + filename
# Check if the file exists # Remove any leading slash characters from the filename, to prevent path traversal attacks
filename = str(filename).lstrip('/\\')
if not filename: if not filename:
exists = False raise FileNotFoundError(_('No image file specified'))
else:
try:
full_path = settings.MEDIA_ROOT.joinpath(filename).resolve()
exists = full_path.exists() and full_path.is_file()
except Exception:
exists = False
if not exists: # Read out the file contents
raise FileNotFoundError(_('Image file not found') + f": '{filename}'") # Note: This will check if the file exists, and raise an error if it does not
data = get_media_file_contents(filename)
# Read the file data
with open(full_path, 'rb') as f:
data = f.read()
# Return the base64-encoded data # Return the base64-encoded data
return 'data:image/svg+xml;charset=utf-8;base64,' + base64.b64encode(data).decode( return 'data:image/svg+xml;charset=utf-8;base64,' + base64.b64encode(data).decode(
@ -323,8 +433,15 @@ def part_image(part: Part, preview: bool = False, thumbnail: bool = False, **kwa
Raises: Raises:
TypeError: If provided part is not a Part instance TypeError: If provided part is not a Part instance
""" """
if type(part) is not Part: if not part or not isinstance(part, Part):
raise TypeError(_('part_image tag requires a Part instance')) raise TypeError(_('part_image tag requires a Part instance'))
image_filename = InvenTree.helpers.image2name(part.image, preview, thumbnail)
if kwargs.get('check_exists'):
if not media_file_exists(image_filename):
raise FileNotFoundError(_('Image file not found') + f": '{image_filename}'")
return uploaded_image( return uploaded_image(
InvenTree.helpers.image2name(part.image, preview, thumbnail), **kwargs InvenTree.helpers.image2name(part.image, preview, thumbnail), **kwargs
) )

View File

@ -14,7 +14,6 @@ from djmoney.money import Money
from PIL import Image from PIL import Image
from common.models import InvenTreeSetting, Parameter, ParameterTemplate from common.models import InvenTreeSetting, Parameter, ParameterTemplate
from InvenTree.config import get_testfolder_dir
from InvenTree.unit_test import InvenTreeTestCase from InvenTree.unit_test import InvenTreeTestCase
from part.models import Part # TODO fix import: PartParameter, PartParameterTemplate from part.models import Part # TODO fix import: PartParameter, PartParameterTemplate
from part.test_api import PartImageTestMixin from part.test_api import PartImageTestMixin
@ -81,7 +80,27 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
self.debug_mode(False) self.debug_mode(False)
asset = report_tags.asset('test.txt') asset = report_tags.asset('test.txt')
self.assertEqual(asset, f'file://{asset_dir}/test.txt')
# Test for attempted path traversal
with self.assertRaises(ValidationError):
report_tags.asset('../../../report/assets/test.txt')
def test_file_access(self):
"""Tests for media and static file access."""
for fn in [None, '', '@@@@@@', 'fake_file.txt']:
self.assertFalse(report_tags.media_file_exists(fn))
self.assertFalse(report_tags.static_file_exists(fn))
with self.assertRaises(FileNotFoundError):
report_tags.get_media_file_contents('dummy_file.txt')
with self.assertRaises(ValueError):
report_tags.get_static_file_contents(None)
# Try again, without throwing an error
self.assertIsNone(
report_tags.get_media_file_contents('dummy_file.txt', raise_error=False)
)
def test_uploaded_image(self): def test_uploaded_image(self):
"""Tests for retrieving uploaded images.""" """Tests for retrieving uploaded images."""
@ -148,6 +167,10 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
) )
self.assertTrue(img.startswith('data:image/png;charset=utf-8;base64,')) self.assertTrue(img.startswith('data:image/png;charset=utf-8;base64,'))
# Attempted path traversal
with self.assertRaises(ValidationError):
report_tags.uploaded_image('../../../part/images/test.jpg')
def test_part_image(self): def test_part_image(self):
"""Unit tests for the 'part_image' tag.""" """Unit tests for the 'part_image' tag."""
with self.assertRaises(TypeError): with self.assertRaises(TypeError):
@ -157,8 +180,10 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
self.create_test_image() self.create_test_image()
obj.refresh_from_db() obj.refresh_from_db()
report_tags.part_image(obj, preview=True) r = report_tags.part_image(obj, preview=True)
report_tags.part_image(obj, thumbnail=True) self.assertIn('data:image/png;charset=utf-8;base64,', r)
r = report_tags.part_image(obj, thumbnail=True)
self.assertIn('data:image/png;charset=utf-8;base64,', r)
def test_company_image(self): def test_company_image(self):
"""Unit tests for the 'company_image' tag.""" """Unit tests for the 'company_image' tag."""
@ -392,12 +417,16 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
def test_encode_svg_image(self): def test_encode_svg_image(self):
"""Test the encode_svg_image template tag.""" """Test the encode_svg_image template tag."""
# Generate smallest possible SVG for testing # Generate smallest possible SVG for testing
svg_path = get_testfolder_dir() / 'part_image_123abc.png' # Store it in the media directory
img_path = 'part_image_123abc.png'
svg_path = settings.MEDIA_ROOT / img_path
with open(svg_path, 'w', encoding='utf8') as f: with open(svg_path, 'w', encoding='utf8') as f:
f.write('<svg xmlns="http://www.w3.org/2000/svg>') f.write('<svg xmlns="http://www.w3.org/2000/svg>')
# Test with a valid SVG file # Test with a valid SVG file
svg = report_tags.encode_svg_image(svg_path) svg = report_tags.encode_svg_image(img_path)
self.assertTrue(svg.startswith('data:image/svg+xml;charset=utf-8;base64,')) self.assertTrue(svg.startswith('data:image/svg+xml;charset=utf-8;base64,'))
self.assertIn('svg', svg) self.assertIn('svg', svg)
self.assertEqual( self.assertEqual(

View File

@ -571,7 +571,9 @@ class TestReportTest(PrintTestMixins, ReportTest):
def test_mdl_salesorder(self): def test_mdl_salesorder(self):
"""Test the SalesOrder model.""" """Test the SalesOrder model."""
self.run_print_test(SalesOrder, 'salesorder', label=False) for enabled in [True, False]:
set_global_setting('REPORT_DEBUG_MODE', enabled)
self.run_print_test(SalesOrder, 'salesorder', label=False)
class AdminTest(AdminTestCase): class AdminTest(AdminTestCase):

View File

@ -37,9 +37,8 @@ from InvenTree.api import (
) )
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import ( from InvenTree.filters import (
ORDER_FILTER_ALIAS, ORDER_FILTER,
SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS,
InvenTreeDateFilter, InvenTreeDateFilter,
NumberOrNullFilter, NumberOrNullFilter,
) )
@ -455,7 +454,7 @@ class StockLocationTree(ListAPI):
queryset = StockLocation.objects.all() queryset = StockLocation.objects.all()
serializer_class = StockSerializers.LocationTreeSerializer serializer_class = StockSerializers.LocationTreeSerializer
filter_backends = ORDER_FILTER_ALIAS filter_backends = ORDER_FILTER
ordering_fields = ['level', 'name', 'sublocations'] ordering_fields = ['level', 'name', 'sublocations']
@ -686,8 +685,8 @@ class StockFilter(FilterSet):
return queryset return queryset
if str2bool(value): if str2bool(value):
return queryset.filter(StockItem.EXPIRED_FILTER) return queryset.filter(StockItem.get_expired_filter())
return queryset.exclude(StockItem.EXPIRED_FILTER) return queryset.exclude(StockItem.get_expired_filter())
external = rest_filters.BooleanFilter( external = rest_filters.BooleanFilter(
label=_('External Location'), method='filter_external' label=_('External Location'), method='filter_external'
@ -1245,14 +1244,19 @@ class StockList(
else: else:
# Create a single StockItem object # Create a single StockItem object
# Note: This automatically creates a tracking entry # Note: This automatically creates a tracking entry
item = serializer.save() item = StockItem(**serializer.validated_data)
if status_value and not item.compare_status(status_value): if status_value and not item.compare_status(status_value):
item.set_status(status_value) item.set_status(status_value)
item.save(user=user) item.save(user=user)
item.refresh_from_db()
response_data = [serializer.data] response_data = [
StockSerializers.StockItemSerializer(
item, context=self.get_serializer_context()
).data
]
return Response( return Response(
response_data, response_data,
@ -1260,7 +1264,7 @@ class StockList(
headers=self.get_success_headers(serializer.data), headers=self.get_success_headers(serializer.data),
) )
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER
ordering_field_aliases = { ordering_field_aliases = {
'part': 'part__name', 'part': 'part__name',

View File

@ -4,8 +4,7 @@ from inspect import signature
from typing import Optional from typing import Optional
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.template import Context, Template
from jinja2 import Template
import common.models import common.models
import InvenTree.exceptions import InvenTree.exceptions
@ -76,7 +75,7 @@ def generate_batch_code(**kwargs):
'STOCK_BATCH_CODE_TEMPLATE', '' 'STOCK_BATCH_CODE_TEMPLATE', ''
) )
return Template(batch_template).render(context) return Template(batch_template).render(Context(context))
def generate_serial_number(part=None, quantity=1, **kwargs) -> Optional[str]: def generate_serial_number(part=None, quantity=1, **kwargs) -> Optional[str]:

View File

@ -10,24 +10,40 @@ def add_part_links(apps, schema_editor):
history_entries = [] history_entries = []
for tracking in StockItemTracking.objects.all(): N = StockItemTracking.objects.count()
if N > 0:
print(f"\nUpdating {N} StockItemTracking entries with part links...")
for tracking in StockItemTracking.objects.filter(part__isnull=True).select_related('item__part'):
item = tracking.item item = tracking.item
# No item link - skip
if item is None: if item is None:
continue continue
part = item.part part = item.part
# No part link - skip
if part is None: if part is None:
continue continue
# Already linked to the correct part - skip
if tracking.part == part:
continue
tracking.part = part tracking.part = part
history_entries.append(tracking) history_entries.append(tracking)
# Process in batches to avoid issues with very large datasets
if len(history_entries) >= 100:
StockItemTracking.objects.bulk_update(history_entries, ['part'])
history_entries = []
print(".", end='', flush=True)
if len(history_entries) > 0: if len(history_entries) > 0:
StockItemTracking.objects.bulk_update(history_entries, ['part']) StockItemTracking.objects.bulk_update(history_entries, ['part'])
print(f"\nUpdated {len(history_entries)} StockItemTracking entries with part links")
def remove_null_items(apps, schema_editor): def remove_null_items(apps, schema_editor):

View File

@ -518,12 +518,14 @@ class StockItem(
status__in=StockStatusGroups.AVAILABLE_CODES, status__in=StockStatusGroups.AVAILABLE_CODES,
) )
# A query filter which can be used to filter StockItem objects which have expired @classmethod
EXPIRED_FILTER = ( def get_expired_filter(cls):
IN_STOCK_FILTER """A query filter which can be used to filter StockItem objects which have expired."""
& ~Q(expiry_date=None) return (
& Q(expiry_date__lt=InvenTree.helpers.current_date()) cls.IN_STOCK_FILTER
) & ~Q(expiry_date=None)
& Q(expiry_date__lt=InvenTree.helpers.current_date())
)
@classmethod @classmethod
def _create_serial_numbers(cls, serials: list, **kwargs) -> QuerySet: def _create_serial_numbers(cls, serials: list, **kwargs) -> QuerySet:

View File

@ -516,7 +516,7 @@ class StockItemSerializer(
queryset = queryset.annotate( queryset = queryset.annotate(
expired=Case( expired=Case(
When( When(
StockItem.EXPIRED_FILTER, StockItem.get_expired_filter(),
then=Value(True, output_field=BooleanField()), then=Value(True, output_field=BooleanField()),
), ),
default=Value(False, output_field=BooleanField()), default=Value(False, output_field=BooleanField()),

View File

@ -575,6 +575,81 @@ class StockItemListTest(StockAPITestCase):
for ordering in ['part', 'location', 'stock', 'status', 'IPN', 'MPN', 'SKU']: for ordering in ['part', 'location', 'stock', 'status', 'IPN', 'MPN', 'SKU']:
self.run_ordering_test(self.list_url, ordering) self.run_ordering_test(self.list_url, ordering)
def test_pagination(self):
"""Test that pagination boundaries are observed correctly.
Ref: https://github.com/inventree/InvenTree/issues/11442
"""
location = StockLocation.objects.first()
part = Part.objects.first()
items = []
# Delete all existing stock item objects
for item in StockItem.objects.all():
item.delete()
for idx in range(1000):
items.append(
StockItem(
part=part,
location=location,
quantity=idx % 10,
level=0,
lft=0,
rght=0,
tree_id=0,
)
)
if len(items) >= 100:
StockItem.objects.bulk_create(items)
items = []
self.assertEqual(StockItem.objects.count(), 1000)
url = reverse('api-stock-list')
# Keep track of the unique PKs we have seen in the results
unique_pks = set()
for idx in range(0, 100, 10):
data = self.get(url, {'limit': 10, 'offset': idx}).data
self.assertEqual(data['count'], 1000)
self.assertEqual(len(data['results']), 10)
for item in data['results']:
self.assertNotIn(
item['pk'],
unique_pks,
f'Duplicate PK {item["pk"]} found in paginated results @ page {idx // 10}',
)
unique_pks.add(item['pk'])
self.assertEqual(
len(unique_pks), 100, 'Expected to see 100 unique PKs in paginated results'
)
# Run same test again, with reverse ordering on part IPN
unique_pks = set()
for idx in range(0, 100, 10):
data = self.get(url, {'limit': 10, 'offset': idx, 'ordering': '-IPN'}).data
self.assertEqual(data['count'], 1000)
self.assertEqual(len(data['results']), 10)
for item in data['results']:
self.assertNotIn(
item['pk'],
unique_pks,
f'Duplicate PK {item["pk"]} found in paginated results @ page {idx // 10} with reverse ordering',
)
unique_pks.add(item['pk'])
self.assertEqual(
len(unique_pks), 100, 'Expected to see 100 unique PKs in paginated results'
)
def test_top_level_filtering(self): def test_top_level_filtering(self):
"""Test filtering against "top level" stock location.""" """Test filtering against "top level" stock location."""
# No filters, should return *all* items # No filters, should return *all* items
@ -962,7 +1037,9 @@ class StockItemListTest(StockAPITestCase):
# Note: While the export is quick on pgsql, it is still quite slow on sqlite3 # Note: While the export is quick on pgsql, it is still quite slow on sqlite3
with self.export_data( with self.export_data(
self.list_url, max_query_count=50, max_query_time=9.0 self.list_url,
max_query_count=50,
max_query_time=12.0, # Test time increased due to worker variability
) as data_file: ) as data_file:
data = self.process_csv(data_file) data = self.process_csv(data_file)
@ -1340,12 +1417,19 @@ class StockItemTest(StockAPITestCase):
"""Test the default location functionality, if a 'location' is not specified in the creation request.""" """Test the default location functionality, if a 'location' is not specified in the creation request."""
# The part 'R_4K7_0603' (pk=4) has a default location specified # The part 'R_4K7_0603' (pk=4) has a default location specified
# Create a new StockItem instance
response = self.post( response = self.post(
self.list_url, data={'part': 4, 'quantity': 10}, expected_code=201 self.list_url, data={'part': 4, 'quantity': 10}, expected_code=201
) )
self.assertEqual(response.data[0]['location'], 2) self.assertEqual(response.data[0]['location'], 2)
# Check that the item was associated with the correct user
item = StockItem.objects.get(pk=response.data[0]['pk'])
self.assertEqual(item.tracking_info_count, 1)
tracking = item.tracking_info.first()
self.assertEqual(tracking.user, self.user)
# What if we explicitly set the location to a different value? # What if we explicitly set the location to a different value?
response = self.post( response = self.post(
@ -2323,17 +2407,7 @@ class StockTestResultTest(StockAPITestCase):
self.delete(url, {}, expected_code=400) self.delete(url, {}, expected_code=400)
# Now, let's delete all the newly created items with a single API request # Now, let's delete all the newly created items with a single API request
# However, we will provide incorrect filters response = self.delete(url, {'items': tests}, expected_code=200)
response = self.delete(
url, {'items': tests, 'filters': {'stock_item': 10}}, expected_code=400
)
self.assertEqual(StockItemTestResult.objects.count(), n + 50)
# Try again, but with the correct filters this time
response = self.delete(
url, {'items': tests, 'filters': {'stock_item': 1}}, expected_code=200
)
self.assertEqual(StockItemTestResult.objects.count(), n) self.assertEqual(StockItemTestResult.objects.count(), n)

View File

@ -367,49 +367,44 @@ class GetAuthToken(GenericAPIView):
- Existing tokens are *never* exposed again via the API - Existing tokens are *never* exposed again via the API
- Once the token is provided, it can be used for auth until it expires - Once the token is provided, it can be used for auth until it expires
""" """
if request.user.is_authenticated: if not request.user.is_authenticated:
user = request.user raise exceptions.NotAuthenticated() # pragma: no cover
name = request.query_params.get('name', '')
name = ApiToken.sanitize_name(name) user = request.user
name = request.query_params.get('name', '')
today = datetime.date.today() name = ApiToken.sanitize_name(name)
# Find existing token, which has not expired today = datetime.date.today()
token = ApiToken.objects.filter(
user=user, name=name, revoked=False, expiry__gte=today
).first()
if not token: # Find existing token, which has not expired
# User is authenticated, and requesting a token against the provided name. token = ApiToken.objects.filter(
token = ApiToken.objects.create(user=request.user, name=name) user=user, name=name, revoked=False, expiry__gte=today
).first()
logger.info( if not token:
"Created new API token for user '%s' (name='%s')", # User is authenticated, and requesting a token against the provided name.
user.username, token = ApiToken.objects.create(user=request.user, name=name)
name,
) logger.info(
"Created new API token for user '%s' (name='%s')", user.username, name
)
# Add some metadata about the request # Add some metadata about the request
token.set_metadata('user_agent', request.headers.get('user-agent', '')) token.set_metadata('user_agent', request.headers.get('user-agent', ''))
token.set_metadata('remote_addr', request.META.get('REMOTE_ADDR', '')) token.set_metadata('remote_addr', request.META.get('REMOTE_ADDR', ''))
token.set_metadata('remote_host', request.META.get('REMOTE_HOST', '')) token.set_metadata('remote_host', request.META.get('REMOTE_HOST', ''))
token.set_metadata('remote_user', request.META.get('REMOTE_USER', '')) token.set_metadata('remote_user', request.META.get('REMOTE_USER', ''))
token.set_metadata('server_name', request.META.get('SERVER_NAME', '')) token.set_metadata('server_name', request.META.get('SERVER_NAME', ''))
token.set_metadata('server_port', request.META.get('SERVER_PORT', '')) token.set_metadata('server_port', request.META.get('SERVER_PORT', ''))
data = {'token': token.key, 'name': token.name, 'expiry': token.expiry} data = {'token': token.key, 'name': token.name, 'expiry': token.expiry}
# Ensure that the users session is logged in # Ensure that the users session is logged in
if not get_user(request).is_authenticated: if not get_user(request).is_authenticated:
login( login(request, user, backend='django.contrib.auth.backends.ModelBackend')
request, user, backend='django.contrib.auth.backends.ModelBackend'
)
return Response(data) return Response(data)
else:
raise exceptions.NotAuthenticated() # pragma: no cover
class TokenMixin: class TokenMixin:

View File

@ -238,8 +238,21 @@ class ApiTokenSerializer(InvenTreeModelSerializer):
def validate(self, data): def validate(self, data):
"""Validate the data for the serializer.""" """Validate the data for the serializer."""
request_user = self.context['request'].user
if not request_user:
raise serializers.ValidationError(
_('User must be authenticated')
) # pragma: no cover
if 'user' not in data: if 'user' not in data:
data['user'] = self.context['request'].user data['user'] = request_user
# Only superusers can create tokens for other users
if data['user'] != request_user and not request_user.is_superuser:
raise serializers.ValidationError(
_('Only a superuser can create a token for another user')
)
return super().validate(data) return super().validate(data)
user_detail = UserSerializer(source='user', read_only=True) user_detail = UserSerializer(source='user', read_only=True)
@ -380,6 +393,9 @@ class MeUserSerializer(ExtendedUserSerializer):
but ensures that certain fields are read-only. but ensures that certain fields are read-only.
""" """
# Remove the 'group_ids' field, as this is not relevant for the 'me' endpoint
fields = [f for f in ExtendedUserSerializer.Meta.fields if f != 'group_ids']
read_only_fields = [ read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields, *ExtendedUserSerializer.Meta.read_only_fields,
'is_active', 'is_active',
@ -389,6 +405,28 @@ class MeUserSerializer(ExtendedUserSerializer):
profile = UserProfileSerializer(many=False, read_only=True) profile = UserProfileSerializer(many=False, read_only=True)
# Redefine the fields from ExtendedUserSerializer, to ensure they are marked as read-only
is_staff = serializers.BooleanField(
label=_('Staff'),
help_text=_('Does this user have staff permissions'),
required=False,
read_only=True,
)
is_superuser = serializers.BooleanField(
label=_('Superuser'),
help_text=_('Is this user a superuser'),
required=False,
read_only=True,
)
is_active = serializers.BooleanField(
label=_('Active'),
help_text=_('Is this user account active'),
required=False,
read_only=True,
)
def make_random_password(length=14): def make_random_password(length=14):
"""Generate a random password of given length.""" """Generate a random password of given length."""

View File

@ -258,6 +258,8 @@ class SuperuserAPITests(InvenTreeAPITestCase):
class UserTokenTests(InvenTreeAPITestCase): class UserTokenTests(InvenTreeAPITestCase):
"""Tests for user token functionality.""" """Tests for user token functionality."""
fixtures = ['users']
def test_token_generation(self): def test_token_generation(self):
"""Test user token generation.""" """Test user token generation."""
url = reverse('api-token') url = reverse('api-token')
@ -396,6 +398,28 @@ class UserTokenTests(InvenTreeAPITestCase):
self.client.logout() self.client.logout()
self.get(reverse('api-token'), expected_code=401) self.get(reverse('api-token'), expected_code=401)
def test_token_security(self):
"""Test that token generation is only available to users with the correct permissions."""
url = reverse('api-token-list')
# Try to generate a token for a different user (should fail)
response = self.post(url, data={'name': 'test', 'user': 1}, expected_code=400)
self.assertIn(
'Only a superuser can create a token for another user', str(response.data)
)
# there should be no tokens created
self.assertEqual(ApiToken.objects.count(), 0)
# now with superuser permissions
self.user.is_superuser = True
self.user.save()
response = self.post(url, data={'name': 'test', 'user': 1}, expected_code=201)
self.assertIn('token', response.data)
self.assertEqual(ApiToken.objects.count(), 1)
class GroupDetialTests(InvenTreeAPITestCase): class GroupDetialTests(InvenTreeAPITestCase):
"""Tests for the GroupDetail API endpoint.""" """Tests for the GroupDetail API endpoint."""

View File

@ -433,56 +433,56 @@ charset-normalizer==3.4.4 \
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# requests # requests
cryptography==46.0.5 \ cryptography==46.0.6 \
--hash=sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72 \ --hash=sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70 \
--hash=sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235 \ --hash=sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d \
--hash=sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9 \ --hash=sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a \
--hash=sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356 \ --hash=sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0 \
--hash=sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257 \ --hash=sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97 \
--hash=sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad \ --hash=sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30 \
--hash=sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4 \ --hash=sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759 \
--hash=sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c \ --hash=sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c \
--hash=sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614 \ --hash=sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead \
--hash=sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed \ --hash=sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275 \
--hash=sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31 \ --hash=sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58 \
--hash=sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229 \ --hash=sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f \
--hash=sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0 \ --hash=sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361 \
--hash=sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731 \ --hash=sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507 \
--hash=sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b \ --hash=sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa \
--hash=sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4 \ --hash=sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b \
--hash=sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4 \ --hash=sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b \
--hash=sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263 \ --hash=sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8 \
--hash=sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595 \ --hash=sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8 \
--hash=sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1 \ --hash=sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72 \
--hash=sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678 \ --hash=sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175 \
--hash=sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48 \ --hash=sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e \
--hash=sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76 \ --hash=sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124 \
--hash=sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0 \ --hash=sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a \
--hash=sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18 \ --hash=sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c \
--hash=sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d \ --hash=sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f \
--hash=sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d \ --hash=sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d \
--hash=sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1 \ --hash=sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4 \
--hash=sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981 \ --hash=sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c \
--hash=sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7 \ --hash=sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290 \
--hash=sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82 \ --hash=sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca \
--hash=sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2 \ --hash=sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d \
--hash=sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4 \ --hash=sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a \
--hash=sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663 \ --hash=sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed \
--hash=sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c \ --hash=sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a \
--hash=sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d \ --hash=sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb \
--hash=sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a \ --hash=sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8 \
--hash=sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a \ --hash=sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707 \
--hash=sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d \ --hash=sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410 \
--hash=sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b \ --hash=sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736 \
--hash=sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a \ --hash=sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2 \
--hash=sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826 \ --hash=sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4 \
--hash=sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee \ --hash=sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013 \
--hash=sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9 \ --hash=sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19 \
--hash=sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648 \ --hash=sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b \
--hash=sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da \ --hash=sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738 \
--hash=sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2 \ --hash=sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463 \
--hash=sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2 \ --hash=sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77 \
--hash=sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87 --hash=sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# -r src/backend/requirements.in # -r src/backend/requirements.in
@ -1659,9 +1659,9 @@ pynacl==1.6.2 \
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# paramiko # paramiko
pypdf==6.6.2 \ pypdf==6.7.5 \
--hash=sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016 \ --hash=sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13 \
--hash=sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba --hash=sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# -r src/backend/requirements.in # -r src/backend/requirements.in
@ -1902,9 +1902,9 @@ referencing==0.37.0 \
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# jsonschema # jsonschema
# jsonschema-specifications # jsonschema-specifications
requests==2.32.5 \ requests==2.33.0 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# django-allauth # django-allauth

View File

@ -329,56 +329,56 @@ coverage[toml]==7.13.1 \
--hash=sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7 \ --hash=sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7 \
--hash=sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766 --hash=sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766
# via -r src/backend/requirements-dev.in # via -r src/backend/requirements-dev.in
cryptography==46.0.5 \ cryptography==46.0.6 \
--hash=sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72 \ --hash=sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70 \
--hash=sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235 \ --hash=sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d \
--hash=sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9 \ --hash=sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a \
--hash=sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356 \ --hash=sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0 \
--hash=sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257 \ --hash=sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97 \
--hash=sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad \ --hash=sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30 \
--hash=sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4 \ --hash=sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759 \
--hash=sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c \ --hash=sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c \
--hash=sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614 \ --hash=sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead \
--hash=sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed \ --hash=sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275 \
--hash=sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31 \ --hash=sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58 \
--hash=sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229 \ --hash=sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f \
--hash=sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0 \ --hash=sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361 \
--hash=sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731 \ --hash=sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507 \
--hash=sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b \ --hash=sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa \
--hash=sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4 \ --hash=sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b \
--hash=sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4 \ --hash=sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b \
--hash=sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263 \ --hash=sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8 \
--hash=sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595 \ --hash=sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8 \
--hash=sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1 \ --hash=sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72 \
--hash=sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678 \ --hash=sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175 \
--hash=sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48 \ --hash=sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e \
--hash=sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76 \ --hash=sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124 \
--hash=sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0 \ --hash=sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a \
--hash=sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18 \ --hash=sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c \
--hash=sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d \ --hash=sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f \
--hash=sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d \ --hash=sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d \
--hash=sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1 \ --hash=sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4 \
--hash=sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981 \ --hash=sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c \
--hash=sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7 \ --hash=sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290 \
--hash=sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82 \ --hash=sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca \
--hash=sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2 \ --hash=sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d \
--hash=sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4 \ --hash=sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a \
--hash=sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663 \ --hash=sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed \
--hash=sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c \ --hash=sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a \
--hash=sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d \ --hash=sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb \
--hash=sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a \ --hash=sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8 \
--hash=sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a \ --hash=sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707 \
--hash=sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d \ --hash=sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410 \
--hash=sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b \ --hash=sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736 \
--hash=sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a \ --hash=sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2 \
--hash=sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826 \ --hash=sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4 \
--hash=sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee \ --hash=sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013 \
--hash=sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9 \ --hash=sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19 \
--hash=sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648 \ --hash=sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b \
--hash=sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da \ --hash=sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738 \
--hash=sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2 \ --hash=sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463 \
--hash=sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2 \ --hash=sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77 \
--hash=sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87 --hash=sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4
# via # via
# -c src/backend/requirements-3.14.txt # -c src/backend/requirements-3.14.txt
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
@ -623,9 +623,9 @@ pyyaml==6.0.3 \
# -c src/backend/requirements-3.14.txt # -c src/backend/requirements-3.14.txt
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# pre-commit # pre-commit
requests==2.32.5 \ requests==2.33.0 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
# via # via
# -c src/backend/requirements-3.14.txt # -c src/backend/requirements-3.14.txt
# -c src/backend/requirements.txt # -c src/backend/requirements.txt

View File

@ -325,56 +325,56 @@ coverage[toml]==7.13.1 \
--hash=sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7 \ --hash=sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7 \
--hash=sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766 --hash=sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766
# via -r src/backend/requirements-dev.in # via -r src/backend/requirements-dev.in
cryptography==46.0.5 \ cryptography==46.0.6 \
--hash=sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72 \ --hash=sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70 \
--hash=sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235 \ --hash=sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d \
--hash=sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9 \ --hash=sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a \
--hash=sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356 \ --hash=sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0 \
--hash=sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257 \ --hash=sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97 \
--hash=sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad \ --hash=sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30 \
--hash=sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4 \ --hash=sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759 \
--hash=sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c \ --hash=sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c \
--hash=sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614 \ --hash=sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead \
--hash=sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed \ --hash=sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275 \
--hash=sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31 \ --hash=sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58 \
--hash=sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229 \ --hash=sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f \
--hash=sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0 \ --hash=sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361 \
--hash=sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731 \ --hash=sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507 \
--hash=sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b \ --hash=sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa \
--hash=sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4 \ --hash=sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b \
--hash=sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4 \ --hash=sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b \
--hash=sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263 \ --hash=sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8 \
--hash=sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595 \ --hash=sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8 \
--hash=sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1 \ --hash=sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72 \
--hash=sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678 \ --hash=sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175 \
--hash=sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48 \ --hash=sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e \
--hash=sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76 \ --hash=sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124 \
--hash=sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0 \ --hash=sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a \
--hash=sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18 \ --hash=sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c \
--hash=sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d \ --hash=sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f \
--hash=sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d \ --hash=sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d \
--hash=sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1 \ --hash=sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4 \
--hash=sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981 \ --hash=sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c \
--hash=sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7 \ --hash=sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290 \
--hash=sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82 \ --hash=sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca \
--hash=sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2 \ --hash=sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d \
--hash=sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4 \ --hash=sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a \
--hash=sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663 \ --hash=sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed \
--hash=sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c \ --hash=sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a \
--hash=sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d \ --hash=sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb \
--hash=sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a \ --hash=sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8 \
--hash=sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a \ --hash=sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707 \
--hash=sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d \ --hash=sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410 \
--hash=sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b \ --hash=sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736 \
--hash=sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a \ --hash=sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2 \
--hash=sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826 \ --hash=sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4 \
--hash=sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee \ --hash=sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013 \
--hash=sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9 \ --hash=sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19 \
--hash=sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648 \ --hash=sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b \
--hash=sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da \ --hash=sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738 \
--hash=sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2 \ --hash=sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463 \
--hash=sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2 \ --hash=sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77 \
--hash=sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87 --hash=sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# pdfminer-six # pdfminer-six
@ -612,9 +612,9 @@ pyyaml==6.0.3 \
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# pre-commit # pre-commit
requests==2.32.5 \ requests==2.33.0 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# requests-mock # requests-mock

View File

@ -419,56 +419,56 @@ charset-normalizer==3.4.4 \
--hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \ --hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \
--hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608 --hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608
# via requests # via requests
cryptography==46.0.5 \ cryptography==46.0.6 \
--hash=sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72 \ --hash=sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70 \
--hash=sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235 \ --hash=sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d \
--hash=sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9 \ --hash=sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a \
--hash=sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356 \ --hash=sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0 \
--hash=sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257 \ --hash=sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97 \
--hash=sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad \ --hash=sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30 \
--hash=sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4 \ --hash=sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759 \
--hash=sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c \ --hash=sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c \
--hash=sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614 \ --hash=sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead \
--hash=sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed \ --hash=sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275 \
--hash=sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31 \ --hash=sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58 \
--hash=sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229 \ --hash=sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f \
--hash=sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0 \ --hash=sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361 \
--hash=sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731 \ --hash=sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507 \
--hash=sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b \ --hash=sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa \
--hash=sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4 \ --hash=sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b \
--hash=sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4 \ --hash=sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b \
--hash=sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263 \ --hash=sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8 \
--hash=sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595 \ --hash=sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8 \
--hash=sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1 \ --hash=sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72 \
--hash=sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678 \ --hash=sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175 \
--hash=sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48 \ --hash=sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e \
--hash=sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76 \ --hash=sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124 \
--hash=sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0 \ --hash=sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a \
--hash=sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18 \ --hash=sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c \
--hash=sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d \ --hash=sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f \
--hash=sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d \ --hash=sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d \
--hash=sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1 \ --hash=sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4 \
--hash=sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981 \ --hash=sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c \
--hash=sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7 \ --hash=sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290 \
--hash=sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82 \ --hash=sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca \
--hash=sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2 \ --hash=sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d \
--hash=sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4 \ --hash=sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a \
--hash=sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663 \ --hash=sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed \
--hash=sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c \ --hash=sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a \
--hash=sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d \ --hash=sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb \
--hash=sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a \ --hash=sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8 \
--hash=sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a \ --hash=sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707 \
--hash=sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d \ --hash=sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410 \
--hash=sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b \ --hash=sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736 \
--hash=sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a \ --hash=sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2 \
--hash=sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826 \ --hash=sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4 \
--hash=sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee \ --hash=sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013 \
--hash=sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9 \ --hash=sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19 \
--hash=sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648 \ --hash=sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b \
--hash=sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da \ --hash=sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738 \
--hash=sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2 \ --hash=sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463 \
--hash=sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2 \ --hash=sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77 \
--hash=sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87 --hash=sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4
# via # via
# -r src/backend/requirements.in # -r src/backend/requirements.in
# django-anymail # django-anymail
@ -1467,9 +1467,9 @@ pynacl==1.6.2 \
--hash=sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2 \ --hash=sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2 \
--hash=sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9 --hash=sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9
# via paramiko # via paramiko
pypdf==6.6.2 \ pypdf==6.7.5 \
--hash=sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016 \ --hash=sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13 \
--hash=sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba --hash=sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d
# via -r src/backend/requirements.in # via -r src/backend/requirements.in
pyphen==0.17.2 \ pyphen==0.17.2 \
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \ --hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
@ -1686,9 +1686,9 @@ referencing==0.37.0 \
# via # via
# jsonschema # jsonschema
# jsonschema-specifications # jsonschema-specifications
requests==2.32.5 \ requests==2.33.0 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
# via # via
# django-allauth # django-allauth
# django-anymail # django-anymail

View File

@ -48,13 +48,22 @@ export default defineConfig({
name: 'chromium', name: 'chromium',
use: { use: {
...devices['Desktop Chrome'] ...devices['Desktop Chrome']
} },
testIgnore: /customization/ // Ignore all tests in the "customization" folder for this project
}, },
{ {
name: 'firefox', name: 'firefox',
use: { use: {
...devices['Desktop Firefox'] ...devices['Desktop Firefox']
} },
testIgnore: /customization/ // Ignore all tests in the "customization" folder for this project
},
{
name: 'customization',
use: {
...devices['Desktop Firefox']
},
testIgnore: /pui_.*\.spec\.ts/ // Ignore all "pui_*.spec.ts" tests for this project
} }
], ],

View File

@ -36,6 +36,11 @@ export const queryClient = new QueryClient({
} }
}); });
export function setTraceId() { export function setTraceId() {
// check if we are in a secure context (https) - if not use of crypto is not allowed
if (!window.isSecureContext) {
return '';
}
const runID = crypto.randomUUID().replace(/-/g, ''); const runID = crypto.randomUUID().replace(/-/g, '');
const traceid = `00-${runID}-${frontendID}-01`; const traceid = `00-${runID}-${frontendID}-01`;
api.defaults.headers['traceparent'] = traceid; api.defaults.headers['traceparent'] = traceid;

View File

@ -112,9 +112,9 @@ export const PdfPreviewComponent: PreviewAreaComponent = forwardRef(
style={{ style={{
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center',
height: '100%', height: '100%',
width: '100%' width: '100%',
paddingTop: '50px'
}} }}
> >
<Trans>Preview not available, click "Reload Preview".</Trans> <Trans>Preview not available, click "Reload Preview".</Trans>

View File

@ -1,8 +1,10 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { ActionIcon } from '@mantine/core'; import { ActionIcon } from '@mantine/core';
import { forwardRef } from 'react'; import { type ReactNode, forwardRef } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useShallow } from 'zustand/react/shallow';
import { useServerApiState } from '../../states/ServerApiState';
import InvenTreeIcon from './inventree.svg'; import InvenTreeIcon from './inventree.svg';
export const InvenTreeLogoHomeButton = forwardRef<HTMLDivElement>( export const InvenTreeLogoHomeButton = forwardRef<HTMLDivElement>(
@ -19,6 +21,21 @@ export const InvenTreeLogoHomeButton = forwardRef<HTMLDivElement>(
} }
); );
export const InvenTreeLogo = () => { /*
* Render the InvenTree logo
* - Uses the custom logo if one is defined on the server
* - Otherwise, uses the default logo
*/
export function InvenTreeLogo(): ReactNode {
const [server] = useServerApiState(
useShallow((state) => [state.server, state.fetchServerApiState])
);
if (server.server && server.customize?.logo) {
return (
<img src={server.customize.logo} alt={t`InvenTree Logo`} height={28} />
);
}
return <img src={InvenTreeIcon} alt={t`InvenTree Logo`} height={28} />; return <img src={InvenTreeIcon} alt={t`InvenTree Logo`} height={28} />;
}; }

View File

@ -36,13 +36,9 @@ export function RenderBuildLine({
}: Readonly<InstanceRenderInterface>): ReactNode { }: Readonly<InstanceRenderInterface>): ReactNode {
return ( return (
<RenderInlineModel <RenderInlineModel
primary={instance.part_detail.full_name} primary={instance.build_reference ?? instance.build}
secondary={instance.quantity} suffix={instance.pk}
suffix={StatusRenderer({ image={instance.part_detail?.thumbnail || instance.part_detail?.image}
status: instance.status_custom_key,
type: ModelType.build
})}
image={instance.part_detail.thumbnail || instance.part_detail.image}
/> />
); );
} }

View File

@ -114,9 +114,10 @@ export type StockColumnProps = TableColumnProps & {
// Render a StockItem instance within a table // Render a StockItem instance within a table
export function StockColumn(props: StockColumnProps): TableColumn { export function StockColumn(props: StockColumnProps): TableColumn {
return { return {
accessor: props.accessor ?? 'stock_item',
title: t`Stock Item`, title: t`Stock Item`,
...props, ...props,
ordering: props.ordering || 'stock',
accessor: props.accessor || 'stock',
render: (record: any) => { render: (record: any) => {
const stock_item = const stock_item =
resolveItem(record, props.accessor ?? 'stock_item_detail') ?? {}; resolveItem(record, props.accessor ?? 'stock_item_detail') ?? {};

View File

@ -303,9 +303,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
}); });
} }
const columnNames: string = cols.map((col) => col.accessor).join(',');
setColumnHash(hashString(columnNames));
return cols; return cols;
}, [ }, [
columns, columns,
@ -315,6 +312,13 @@ export function InvenTreeTable<T extends Record<string, any>>({
tableState.selectedRecords tableState.selectedRecords
]); ]);
useEffect(() => {
const columnNames: string = dataColumns
.map((col: any) => col.accessor)
.join(',');
setColumnHash(hashString(columnNames));
}, [dataColumns]);
// Callback when column visibility is toggled // Callback when column visibility is toggled
const toggleColumn = useCallback( const toggleColumn = useCallback(
(columnName: string) => { (columnName: string) => {
@ -343,6 +347,13 @@ export function InvenTreeTable<T extends Record<string, any>>({
getInitialValueInEffect: false getInitialValueInEffect: false
}); });
// Reset column ordering and custom widths when the component is mounted
// Ref: https://github.com/icflorescu/mantine-datatable/issues/759#issuecomment-4148942070
useEffect(() => {
tableColumns.resetColumnsOrder();
tableColumns.resetColumnsWidth();
}, []);
// Reset the pagination state when the search term changes // Reset the pagination state when the search term changes
useEffect(() => { useEffect(() => {
tableState.setPage(1); tableState.setPage(1);
@ -486,6 +497,10 @@ export function InvenTreeTable<T extends Record<string, any>>({
tableState.setPage(1); tableState.setPage(1);
setSortStatus(status); setSortStatus(status);
if (!status.columnAccessor) {
console.error('Invalid column accessor provided for table sorting');
}
setTableSorting(cacheKey)(status); setTableSorting(cacheKey)(status);
}, },
[cacheKey] [cacheKey]

View File

@ -533,6 +533,7 @@ export default function BuildOutputTable({
title: t`Complete`, title: t`Complete`,
tooltip: t`Complete build output`, tooltip: t`Complete build output`,
color: 'green', color: 'green',
hidden: !production,
icon: <InvenTreeIcon icon='success' />, icon: <InvenTreeIcon icon='success' />,
onClick: () => { onClick: () => {
setSelectedOutputs([record]); setSelectedOutputs([record]);
@ -568,7 +569,7 @@ export default function BuildOutputTable({
} }
]; ];
}, },
[buildStatus, user, partId, hasTrackedItems] [buildStatus, build.status, user, partId, hasTrackedItems]
); );
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {

View File

@ -220,7 +220,6 @@ export default function PluginListTable() {
// Uninstall an installed plugin // Uninstall an installed plugin
// Must be inactive, not a builtin, not a sample, and installed as a package // Must be inactive, not a builtin, not a sample, and installed as a package
hidden: hidden:
!user.isSuperuser() ||
record.active || record.active ||
record.is_builtin || record.is_builtin ||
record.is_mandatory || record.is_mandatory ||
@ -244,8 +243,7 @@ export default function PluginListTable() {
record.is_builtin || record.is_builtin ||
record.is_mandatory || record.is_mandatory ||
record.is_sample || record.is_sample ||
record.is_installed || record.is_installed,
!user.isSuperuser(),
title: t`Delete`, title: t`Delete`,
tooltip: t`Delete selected plugin configuration`, tooltip: t`Delete selected plugin configuration`,
color: 'red', color: 'red',
@ -355,7 +353,12 @@ export default function PluginListTable() {
// Custom table actions // Custom table actions
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
if (!user.isSuperuser() || !server.plugins_enabled) { if (
!user.isSuperuser() ||
!server.plugins_enabled ||
server.plugins_install_disabled
) {
// Prevent installation if plugins are disabled or user is not superuser
return []; return [];
} }
@ -376,7 +379,6 @@ export default function PluginListTable() {
setPluginPackage(''); setPluginPackage('');
installPluginModal.open(); installPluginModal.open();
}} }}
disabled={server.plugins_install_disabled || false}
/> />
]; ];
}, [user, server]); }, [user, server]);

View File

@ -26,7 +26,8 @@ export function ApiTokenTable({
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const generateToken = useCreateApiFormModal({ const generateToken = useCreateApiFormModal({
url: ApiEndpoints.user_tokens, url: ApiEndpoints.user_token,
method: 'GET',
title: t`Generate Token`, title: t`Generate Token`,
fields: { name: {} }, fields: { name: {} },
successMessage: t`Token generated`, successMessage: t`Token generated`,
@ -178,6 +179,7 @@ export function ApiTokenTable({
onClose={close} onClose={close}
title={<StylishText size='xl'>{t`Token`}</StylishText>} title={<StylishText size='xl'>{t`Token`}</StylishText>}
centered centered
data-testid='generated-api-token'
> >
<Text c='dimmed'> <Text c='dimmed'>
<Trans> <Trans>

View File

@ -0,0 +1,33 @@
import test, { expect } from '@playwright/test';
import { navigate } from '../helpers';
import { doLogin } from '../login';
/**
* Tests for user interface customization functionality.
*
* Note: The correct environment variables must be set for these tests to work correctly. See "playwright.config.ts" for details.
* These tests are designed to run in CI environments where specific environment variables are set to enable custom logos and splash screens. The tests verify that these customizations are correctly applied in the user interface.
*/
test('Customization - Splash', async ({ page }) => {
await navigate(page, '/');
await page.waitForLoadState('networkidle');
// Check for the custom splash screen
await expect(
page.locator('[style*="playwright_custom_splash.png"]')
).toBeVisible();
});
test('Customization - Logo', async ({ page }) => {
await doLogin(page, {
username: 'noaccess',
password: 'youshallnotpass'
});
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2500);
return;
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -36,6 +36,25 @@ test('Stock - Basic Tests', async ({ browser }) => {
await loadTab(page, 'Test Results'); await loadTab(page, 'Test Results');
await page.getByText('395c6d5586e5fb656901d047be27e1f7').waitFor(); await page.getByText('395c6d5586e5fb656901d047be27e1f7').waitFor();
await loadTab(page, 'Installed Items'); await loadTab(page, 'Installed Items');
// Let's create a new stock item
await navigate(page, 'part/822/stock');
await page
.getByRole('button', { name: 'action-button-add-stock-item' })
.click();
await page
.getByRole('textbox', { name: 'number-field-quantity' })
.fill('987');
await page.getByRole('button', { name: 'Submit' }).click();
// Automatically navigate through to the newly created stock item
await page.getByText('Quantity: 987').first().waitFor();
await loadTab(page, 'Stock Tracking');
await page
.getByRole('cell', { name: 'Stock item created' })
.first()
.waitFor();
await page.getByRole('cell', { name: 'allaccess Ally Access' }).waitFor();
}); });
test('Stock - Location Tree', async ({ browser }) => { test('Stock - Location Tree', async ({ browser }) => {

View File

@ -1,8 +1,30 @@
import { expect, test } from './baseFixtures.js'; import { expect, test } from './baseFixtures.js';
import { adminuser } from './defaults.js';
import { activateTableView, loadTab } from './helpers.js'; import { activateTableView, loadTab } from './helpers.js';
import { doCachedLogin } from './login.js'; import { doCachedLogin } from './login.js';
import { setPluginState } from './settings.js'; import { setPluginState } from './settings.js';
// Test for the label editing interface
test('Printing - Label Editing', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: adminuser.username,
password: adminuser.password,
url: 'settings/admin/labels'
});
// Open a particular label template for editing
await page.getByRole('cell', { name: 'Sample build line label' }).click();
// Await expected entries
await page.getByRole('tab', { name: 'PDF Preview' }).waitFor();
await page.getByText('This is an example template').waitFor();
await page
.locator('div')
.filter({ hasText: /^BO\d+$/ })
.first()
.waitFor();
});
/* /*
* Test for label printing. * Test for label printing.
* Select a number of stock items from the table, * Select a number of stock items from the table,

View File

@ -543,3 +543,27 @@ async function testColorPicker(page, ref: string) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height + 25); await page.mouse.click(box.x + box.width / 2, box.y + box.height + 25);
await page.getByText('Color Mode').click(); await page.getByText('Color Mode').click();
} }
test('Settings - Auth - Tokens', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'allaccess',
password: 'nolimits',
url: 'settings/user/'
});
await page.getByRole('tab', { name: 'Security' }).click();
await page.getByRole('button', { name: 'Access Tokens' }).click();
await page
.getByRole('button', { name: 'action-button-generate-token' })
.click();
await page
.getByRole('textbox', { name: 'text-field-name' })
.fill('testtoken');
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page.getByText('Tokens are only shown once').waitFor();
await page
.getByTestId('generated-api-token')
.locator('.mantine-CloseButton-root')
.click();
await page.getByRole('cell', { name: 'testtoken' }).waitFor();
});