Compare commits
47 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
01bf600004 | |
|
|
6bb6bcca2e | |
|
|
d737a7d7a2 | |
|
|
50ac52f09a | |
|
|
da1be76352 | |
|
|
b19528b341 | |
|
|
c5ff2f514f | |
|
|
badd6e431b | |
|
|
add122faf1 | |
|
|
b810b47a4a | |
|
|
651e1c0dac | |
|
|
2bc9e0f0d2 | |
|
|
604f5fdb45 | |
|
|
ee3d01cf73 | |
|
|
3e447066c6 | |
|
|
17fd3174fc | |
|
|
c92f1c3413 | |
|
|
65f1a4bde5 | |
|
|
8077849fc5 | |
|
|
4789a202ee | |
|
|
60f2d9d314 | |
|
|
c173c4e051 | |
|
|
73cab199f1 | |
|
|
e749ece58e | |
|
|
e5ace7d761 | |
|
|
5bf41f1463 | |
|
|
59f3b9f2f6 | |
|
|
f2fa65ff1d | |
|
|
9cfec02a69 | |
|
|
c35ab145a4 | |
|
|
b611960d9f | |
|
|
12da3b01fc | |
|
|
2df9eb80ca | |
|
|
44387f3bb4 | |
|
|
ed8331e26a | |
|
|
228577c871 | |
|
|
fd7d0bda0f | |
|
|
90e5169283 | |
|
|
198594d7d3 | |
|
|
dededb0aa1 | |
|
|
eb413bcc57 | |
|
|
9b11cd294e | |
|
|
ccf5b39bf1 | |
|
|
eceaa68c32 | |
|
|
ccb4ea248b | |
|
|
9b3b68c8d8 | |
|
|
3752b610c8 |
|
|
@ -37,9 +37,10 @@ RUN apt install -y nodejs npm
|
|||
# Update to the latest stable node version
|
||||
RUN npm install -g n --ignore-scripts && n lts
|
||||
|
||||
# Enable yarn
|
||||
RUN corepack enable && corepack prepare yarn@stable --activate
|
||||
# Install yarn
|
||||
RUN npm install -g yarn
|
||||
|
||||
# Ensure node and yarn are available at the command line
|
||||
RUN node --version && yarn --version
|
||||
|
||||
RUN yarn config set httpTimeout 600000 --home
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ inputs:
|
|||
required: false
|
||||
description: 'Install the InvenTree requirements?'
|
||||
default: 'false'
|
||||
static:
|
||||
required: false
|
||||
description: 'Should the static files be built?'
|
||||
default: 'false'
|
||||
dev-install:
|
||||
required: false
|
||||
description: 'Install the InvenTree development requirements?'
|
||||
|
|
@ -103,3 +107,7 @@ runs:
|
|||
if: ${{ inputs.update == 'true' }}
|
||||
shell: bash
|
||||
run: invoke update --skip-backup --skip-static
|
||||
- name: Collect static files
|
||||
if: ${{ inputs.static == 'true' }}
|
||||
shell: bash
|
||||
run: invoke static --skip-plugins
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ env:
|
|||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
INVENTREE_DEBUG: true
|
||||
|
||||
use_performance: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
|
@ -78,7 +80,7 @@ jobs:
|
|||
- name: Which runner to use?
|
||||
id: runner-perf
|
||||
# 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:
|
||||
name: Style [pre-commit]
|
||||
|
|
@ -320,6 +322,7 @@ jobs:
|
|||
apt-dependency: gettext poppler-utils
|
||||
dev-install: true
|
||||
update: true
|
||||
static: true
|
||||
npm: true
|
||||
- name: Download Python Code For `${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
|
||||
dev-install: true
|
||||
update: true
|
||||
static: true
|
||||
- name: Data Export Test
|
||||
uses: ./.github/actions/migration
|
||||
- name: Test Translations
|
||||
|
|
@ -478,6 +482,7 @@ jobs:
|
|||
pip-dependency: psycopg django-redis>=5.0.0
|
||||
dev-install: true
|
||||
update: true
|
||||
static: true
|
||||
- name: Run Tests
|
||||
run: invoke dev.test --check --translations
|
||||
- name: Data Export Test
|
||||
|
|
@ -526,6 +531,7 @@ jobs:
|
|||
pip-dependency: mysqlclient
|
||||
dev-install: true
|
||||
update: true
|
||||
static: true
|
||||
- name: Run Tests
|
||||
run: invoke dev.test --check --translations
|
||||
- name: Data Export Test
|
||||
|
|
@ -692,7 +698,13 @@ jobs:
|
|||
cd src/frontend && npx playwright install --with-deps
|
||||
- name: Run Playwright 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
|
||||
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -85,6 +85,11 @@ jobs:
|
|||
asset_name: frontend-build.zip
|
||||
tag: ${{ github.ref }}
|
||||
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
|
||||
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # pin@2.11.3
|
||||
with:
|
||||
|
|
@ -136,3 +141,99 @@ jobs:
|
|||
asset_name: docs-html.zip
|
||||
tag: ${{ github.ref }}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ buildpack: https://github.com/matmair/null-buildpack#master
|
|||
env:
|
||||
- INVENTREE_DB_ENGINE=sqlite3
|
||||
- INVENTREE_DB_NAME=database.sqlite3
|
||||
- INVENTREE_PLUGINS_ENABLED
|
||||
- INVENTREE_PLUGINS_ENABLED=true
|
||||
- INVENTREE_MEDIA_ROOT=/opt/inventree/media
|
||||
- INVENTREE_STATIC_ROOT=/opt/inventree/static
|
||||
- INVENTREE_BACKUP_DIR=/opt/inventree/backup
|
||||
|
|
@ -20,6 +20,7 @@ before:
|
|||
- contrib/packager.io/before.sh
|
||||
dependencies:
|
||||
- curl
|
||||
- poppler-utils
|
||||
- "python3.11 | python3.12 | python3.13 | python3.14"
|
||||
- "python3.11-venv | python3.12-venv | python3.13-venv | python3.14-venv"
|
||||
- "python3.11-dev | python3.12-dev | python3.13-dev | python3.14-dev"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ repos:
|
|||
exclude: mkdocs.yml
|
||||
- id: mixed-line-ending
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.10
|
||||
rev: v0.15.1
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
args: [--preview]
|
||||
|
|
@ -29,7 +29,7 @@ repos:
|
|||
--preview
|
||||
]
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.9.22
|
||||
rev: 0.10.2
|
||||
hooks:
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements-dev.in
|
||||
|
|
@ -79,7 +79,7 @@ repos:
|
|||
src/frontend/vite.config.ts |
|
||||
)$
|
||||
- repo: https://github.com/biomejs/pre-commit
|
||||
rev: v2.3.10
|
||||
rev: v2.3.15
|
||||
hooks:
|
||||
- id: biome-check
|
||||
additional_dependencies: ["@biomejs/biome@1.9.4"]
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
# 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/),
|
||||
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
|
||||
|
||||
- [#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.
|
||||
|
||||
### 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)
|
||||
- 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)
|
||||
- Allow input controls within generated PDF reports in [#10969](https://github.com/inventree/InvenTree/pull/10969)
|
||||
|
||||
### Changed
|
||||
|
||||
-
|
||||
- Improved stocktake functionality in [#11257](https://github.com/inventree/InvenTree/pull/11257)
|
||||
|
||||
### Removed
|
||||
- Removed python 3.9 / 3.10 support as part of Django 5.2 upgrade in [#10730](https://github.com/inventree/InvenTree/pull/10730)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
# - Runs InvenTree web server under django development 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
|
||||
ARG commit_tag=""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Packages needed for CI/packages
|
||||
requests==2.32.5
|
||||
requests==2.33.0
|
||||
pyyaml==6.0.3
|
||||
jc==1.25.6
|
||||
|
|
|
|||
|
|
@ -214,9 +214,9 @@ pyyaml==6.0.3 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r contrib/dev_reqs/requirements.in
|
||||
requests==2.32.5 \
|
||||
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
|
||||
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
|
||||
requests==2.33.0 \
|
||||
--hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
|
||||
--hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r contrib/dev_reqs/requirements.in
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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") }}
|
||||
|
||||
!!! info "Superuser Required"
|
||||
Only users with superuser privileges can manage plugins via the web interface.
|
||||
|
||||
#### 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).
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| **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
|
||||
|
||||
If a user does not have the required permissions to perform a certain action in the admin interface, those options not be displayed.
|
||||
|
|
|
|||
|
|
@ -401,14 +401,10 @@ It is also possible to use alternative storage backends for static and media fil
|
|||
| Environment Variable | Configuration File | Description | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| INVENTREE_S3_ACCESS_KEY | storage.s3.access_key | Access key | *Not specified* |
|
||||
| INVENTREE_S3_SECRET_KEY | storage.s3.secret_key | Secret key |
|
||||
| *Not specified* |
|
||||
| INVENTREE_S3_BUCKET_NAME | storage.s3.bucket_name | Bucket name, required by most providers |
|
||||
| *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_SECRET_KEY | storage.s3.secret_key | Secret key | *Not specified* |
|
||||
| INVENTREE_S3_BUCKET_NAME | storage.s3.bucket_name | Bucket name, required by most providers | *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_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 |
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ Once the migration process completes, the database records are now updated! Rest
|
|||
|
||||
## 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"
|
||||
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"
|
||||
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:
|
||||
|
||||
|
|
@ -154,13 +154,13 @@ docker compose down
|
|||
|
||||
### 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"
|
||||
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"
|
||||
|
|
@ -168,7 +168,7 @@ rm -rf ./path/to/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
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ InvenTree uses the [Redis](https://redis.io/) cache server to manage cache data.
|
|||
|
||||
!!! info "Redis on Docker"
|
||||
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"
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Each *Stock Item* is linked to the following information:
|
|||
|
||||
**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
|
||||
|
||||
|
|
|
|||
|
|
@ -554,9 +554,9 @@ pyyaml-env-tag==1.1 \
|
|||
--hash=sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04 \
|
||||
--hash=sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff
|
||||
# via mkdocs
|
||||
requests==2.32.5 \
|
||||
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
|
||||
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
|
||||
requests==2.33.0 \
|
||||
--hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
|
||||
--hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# mkdocs-macros-plugin
|
||||
|
|
|
|||
|
|
@ -1,2 +1,5 @@
|
|||
# Files generated during unit testing
|
||||
_testfolder/
|
||||
|
||||
# Playwright files for CI
|
||||
InvenTree/static/img/playwright*.png
|
||||
|
|
|
|||
|
|
@ -423,23 +423,19 @@ class BulkOperationMixin:
|
|||
def get_bulk_queryset(self, request):
|
||||
"""Return a queryset based on the selection made in the request.
|
||||
|
||||
Selection can be made by providing either:
|
||||
|
||||
- items: A list of primary key values
|
||||
- filters: A dictionary of filter values
|
||||
Selection can be made by providing a list of primary key values,
|
||||
which will be used to filter the queryset.
|
||||
"""
|
||||
model = self.serializer_class.Meta.model
|
||||
|
||||
items = request.data.pop('items', None)
|
||||
filters = request.data.pop('filters', 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({
|
||||
'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')
|
||||
})
|
||||
|
||||
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):
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('All filter must only be used with true')
|
||||
|
|
|
|||
|
|
@ -331,6 +331,11 @@ class InvenTreeConfig(AppConfig):
|
|||
if MIGRATIONS_CHECK_DONE:
|
||||
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():
|
||||
# Detect if this an empty database - if so, start with a fresh migration
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -111,9 +111,11 @@ class InvenTreeOrderingFilter(filters.OrderingFilter):
|
|||
|
||||
def get_ordering(self, request, queryset, view):
|
||||
"""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)
|
||||
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
|
||||
if ordering is not None and aliases is not None:
|
||||
|
|
@ -123,9 +125,8 @@ class InvenTreeOrderingFilter(filters.OrderingFilter):
|
|||
ordering = []
|
||||
|
||||
for field in ordering_initial:
|
||||
reverse = field.startswith('-')
|
||||
|
||||
if reverse:
|
||||
field_reversed = field.startswith('-')
|
||||
if field_reversed:
|
||||
field = field[1:]
|
||||
|
||||
# Are aliases defined for this field?
|
||||
|
|
@ -153,11 +154,22 @@ class InvenTreeOrderingFilter(filters.OrderingFilter):
|
|||
continue
|
||||
|
||||
for a in alias:
|
||||
if reverse:
|
||||
if field_reversed:
|
||||
a = '-' + 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
|
||||
|
||||
|
||||
|
|
@ -220,18 +232,10 @@ class NumericInFilter(rest_filters.BaseInFilter):
|
|||
return super().filter(qs, numeric_values)
|
||||
|
||||
|
||||
SEARCH_ORDER_FILTER = [
|
||||
drf_backend.DjangoFilterBackend,
|
||||
InvenTreeSearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
ORDER_FILTER = [drf_backend.DjangoFilterBackend, InvenTreeOrderingFilter]
|
||||
|
||||
SEARCH_ORDER_FILTER_ALIAS = [
|
||||
SEARCH_ORDER_FILTER = [
|
||||
drf_backend.DjangoFilterBackend,
|
||||
InvenTreeSearchFilter,
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
ORDER_FILTER = [drf_backend.DjangoFilterBackend, filters.OrderingFilter]
|
||||
|
||||
ORDER_FILTER_ALIAS = [drf_backend.DjangoFilterBackend, InvenTreeOrderingFilter]
|
||||
|
|
|
|||
|
|
@ -229,8 +229,15 @@ def getStaticUrl(filename):
|
|||
return os.path.join(STATIC_URL, str(filename))
|
||||
|
||||
|
||||
def TestIfImage(img):
|
||||
"""Test if an image file is indeed an image."""
|
||||
def TestIfImage(img) -> bool:
|
||||
"""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:
|
||||
Image.open(img).verify()
|
||||
return True
|
||||
|
|
@ -250,7 +257,6 @@ def getBlankThumbnail():
|
|||
|
||||
def getLogoImage(as_file=False, custom=True):
|
||||
"""Return the InvenTree logo image, or a custom logo if available."""
|
||||
"""Return the path to the logo-file."""
|
||||
if custom and settings.CUSTOM_LOGO:
|
||||
static_storage = StaticFilesStorage()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"""Provides helper functions used throughout the InvenTree project that access the database."""
|
||||
|
||||
import io
|
||||
import ipaddress
|
||||
import socket
|
||||
from decimal import Decimal
|
||||
from typing import Optional, cast
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""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(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)
|
||||
max_size = (
|
||||
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(
|
||||
remote_url,
|
||||
timeout=timeout,
|
||||
allow_redirects=True,
|
||||
allow_redirects=False,
|
||||
stream=True,
|
||||
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
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
|
|
@ -143,6 +204,8 @@ def download_image_from_url(remote_url, timeout=2.5):
|
|||
raise requests.exceptions.HTTPError(
|
||||
_('Server responded with invalid status code') + f': {response.status_code}'
|
||||
)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise Exception(_('Exception occurred') + f': {exc!s}')
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from django.http import Http404
|
|||
from django.urls import reverse
|
||||
|
||||
import structlog
|
||||
from rest_framework import exceptions, serializers
|
||||
from rest_framework import exceptions, permissions, serializers
|
||||
from rest_framework.fields import empty
|
||||
from rest_framework.metadata import SimpleMetadata
|
||||
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
|
||||
for method, permission in rolemap.items():
|
||||
# general model / role permission
|
||||
result = check_user_permission(user, self.model, permission) or (
|
||||
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:
|
||||
del actions[method]
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ from error_report.middleware import ExceptionProcessor
|
|||
from common.settings import get_global_setting
|
||||
from InvenTree.cache import create_session_cache, delete_session_cache
|
||||
from InvenTree.config import CONFIG_LOOKUPS, inventreeInstaller
|
||||
from InvenTree.version import (
|
||||
inventreeApiVersion,
|
||||
inventreePythonVersion,
|
||||
inventreeVersion,
|
||||
)
|
||||
from users.models import ApiToken
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
|
@ -393,3 +398,15 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
|
|||
|
||||
# All checks passed
|
||||
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
|
||||
|
|
|
|||
|
|
@ -26,12 +26,7 @@ from corsheaders.defaults import default_headers as default_cors_headers
|
|||
|
||||
import InvenTree.backup
|
||||
from InvenTree.cache import get_cache_config, is_global_cache_enabled
|
||||
from InvenTree.config import (
|
||||
get_boolean_setting,
|
||||
get_custom_file,
|
||||
get_oidc_private_key,
|
||||
get_setting,
|
||||
)
|
||||
from InvenTree.config import get_boolean_setting, get_oidc_private_key, get_setting
|
||||
from InvenTree.ready import isInMainThread
|
||||
from InvenTree.sentry import default_sentry_dsn, init_sentry
|
||||
from InvenTree.version import checkMinPythonVersion, inventreeCommitHash
|
||||
|
|
@ -203,6 +198,11 @@ PLUGINS_INSTALL_DISABLED = get_boolean_setting(
|
|||
'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 test settings
|
||||
|
|
@ -367,6 +367,7 @@ MIDDLEWARE = CONFIG.get(
|
|||
'InvenTree.middleware.InvenTreeRequestCacheMiddleware', # Request caching
|
||||
'InvenTree.middleware.InvenTreeHostSettingsMiddleware', # Ensuring correct hosting/security settings
|
||||
'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)
|
||||
|
||||
# User interface customization values
|
||||
CUSTOM_LOGO = get_custom_file(
|
||||
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True
|
||||
)
|
||||
CUSTOM_SPLASH = get_custom_file(
|
||||
'INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash'
|
||||
)
|
||||
CUSTOM_LOGO = get_setting('INVENTREE_CUSTOM_LOGO', 'customize.logo', typecast=str)
|
||||
|
||||
CUSTOM_SPLASH = get_setting('INVENTREE_CUSTOM_SPLASH', 'customize.splash', typecast=str)
|
||||
|
||||
CUSTOMIZE = get_setting(
|
||||
'INVENTREE_CUSTOMIZE', 'customize', default_value=None, typecast=dict
|
||||
|
|
|
|||
|
|
@ -587,6 +587,15 @@ def update_exchange_rates(force: bool = False):
|
|||
Arguments:
|
||||
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:
|
||||
from djmoney.contrib.exchange.models import Rate
|
||||
|
||||
|
|
|
|||
|
|
@ -278,8 +278,7 @@ class BulkDeleteTests(InvenTreeAPITestCase):
|
|||
response = self.delete(url, {}, expected_code=400)
|
||||
|
||||
self.assertIn(
|
||||
'List of items or filters must be provided for bulk operation',
|
||||
str(response.data),
|
||||
'List of items must be provided for bulk operation', str(response.data)
|
||||
)
|
||||
|
||||
# DELETE with invalid 'items'
|
||||
|
|
@ -287,11 +286,6 @@ class BulkDeleteTests(InvenTreeAPITestCase):
|
|||
|
||||
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):
|
||||
"""Unit tests for global search endpoint."""
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from django.conf import settings
|
|||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = '1.2.0 dev'
|
||||
INVENTREE_SW_VERSION = '1.2.7'
|
||||
|
||||
# Minimum supported Python version
|
||||
MIN_PYTHON_VERSION = (3, 11)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from generic.states.api import StatusView
|
|||
from InvenTree.api import BulkDeleteMixin, ParameterListMixin, meta_path
|
||||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||
from InvenTree.filters import (
|
||||
SEARCH_ORDER_FILTER_ALIAS,
|
||||
SEARCH_ORDER_FILTER,
|
||||
InvenTreeDateFilter,
|
||||
NumberOrNullFilter,
|
||||
)
|
||||
|
|
@ -145,8 +145,8 @@ class BuildFilter(FilterSet):
|
|||
def filter_overdue(self, queryset, name, value):
|
||||
"""Filter the queryset to either include or exclude orders which are overdue."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(Build.OVERDUE_FILTER)
|
||||
return queryset.exclude(Build.OVERDUE_FILTER)
|
||||
return queryset.filter(Build.get_overdue_filter())
|
||||
return queryset.exclude(Build.get_overdue_filter())
|
||||
|
||||
assigned_to_me = rest_filters.BooleanFilter(
|
||||
label=_('Assigned to me'), method='filter_assigned_to_me'
|
||||
|
|
@ -343,7 +343,7 @@ class BuildList(
|
|||
|
||||
output_options = BuildListOutputOptions
|
||||
filterset_class = BuildFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
ordering_fields = [
|
||||
'reference',
|
||||
'part',
|
||||
|
|
@ -594,7 +594,7 @@ class BuildLineList(
|
|||
"""API endpoint for accessing a list of BuildLine objects."""
|
||||
|
||||
filterset_class = BuildLineFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
output_options = BuildLineOutputOptions
|
||||
ordering_fields = [
|
||||
'part',
|
||||
|
|
@ -951,7 +951,7 @@ class BuildItemList(
|
|||
|
||||
output_options = BuildItemOutputOptions
|
||||
filterset_class = BuildItemFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method, to perform custom prefetch."""
|
||||
|
|
|
|||
|
|
@ -123,11 +123,14 @@ class Build(
|
|||
|
||||
order_insertion_by = ['reference']
|
||||
|
||||
OVERDUE_FILTER = (
|
||||
Q(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__lte=InvenTree.helpers.current_date())
|
||||
)
|
||||
@classmethod
|
||||
def get_overdue_filter(cls):
|
||||
"""Filter for determining if a build order is overdue."""
|
||||
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
|
||||
REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN'
|
||||
|
|
@ -456,7 +459,7 @@ class Build(
|
|||
bool: Is the build overdue
|
||||
"""
|
||||
query = Build.objects.filter(pk=self.pk)
|
||||
query = query.filter(Build.OVERDUE_FILTER)
|
||||
query = query.filter(Build.get_overdue_filter())
|
||||
|
||||
return query.exists()
|
||||
|
||||
|
|
|
|||
|
|
@ -168,7 +168,8 @@ class BuildSerializer(
|
|||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
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()),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -48,11 +48,7 @@ from InvenTree.api import (
|
|||
meta_path,
|
||||
)
|
||||
from InvenTree.config import CONFIG_LOOKUPS
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER_ALIAS,
|
||||
)
|
||||
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
|
||||
from InvenTree.helpers import inheritors, str2bool
|
||||
from InvenTree.helpers_email import send_email
|
||||
from InvenTree.mixins import (
|
||||
|
|
@ -918,7 +914,7 @@ class ParameterList(
|
|||
"""List API endpoint for Parameter objects."""
|
||||
|
||||
filterset_class = ParameterFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_fields = ['name', 'data', 'units', 'template', 'updated', 'updated_by']
|
||||
|
||||
|
|
@ -1005,6 +1001,22 @@ class DataOutputEndpointMixin:
|
|||
serializer_class = common.serializers.DataOutputSerializer
|
||||
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):
|
||||
"""List view for DataOutput objects."""
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ logger = structlog.get_logger('inventree')
|
|||
def currency_code_default(create: bool = True):
|
||||
"""Returns the default currency code (or USD if not specified)."""
|
||||
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 = ''
|
||||
|
||||
|
|
|
|||
|
|
@ -1216,7 +1216,9 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||
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
|
||||
|
||||
|
|
@ -1282,9 +1284,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||
|
||||
The keys must be upper-case
|
||||
"""
|
||||
from common.setting.system import SYSTEM_SETTINGS
|
||||
|
||||
SETTINGS = SYSTEM_SETTINGS
|
||||
|
||||
typ = 'inventree'
|
||||
|
||||
|
|
@ -1310,6 +1309,8 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
|||
|
||||
import common.setting.user
|
||||
|
||||
SETTINGS = common.setting.user.USER_SETTINGS
|
||||
|
||||
CHECK_SETTING_KEY = True
|
||||
|
||||
class Meta:
|
||||
|
|
@ -1321,8 +1322,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
|||
models.UniqueConstraint(fields=['key', 'user'], name='unique key and user')
|
||||
]
|
||||
|
||||
SETTINGS = common.setting.user.USER_SETTINGS
|
||||
|
||||
typ = 'user'
|
||||
extra_unique_fields = ['user']
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from django.core.exceptions import FieldDoesNotExist, ValidationError
|
|||
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from jinja2 import Template
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
import build.validators
|
||||
import common.currency
|
||||
|
|
@ -49,10 +49,11 @@ def validate_part_name_format(value):
|
|||
})
|
||||
|
||||
# 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:
|
||||
Template(value).render({'part': p})
|
||||
SandboxedEnvironment().from_string(value).render({'part': p})
|
||||
except Exception as exc:
|
||||
raise ValidationError({'value': str(exc)})
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""Set the value of a global setting using the provided key."""
|
||||
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['create'] = create
|
||||
|
|
|
|||
|
|
@ -6,6 +6,44 @@ import common.models
|
|||
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):
|
||||
"""Tests for the Parameter API."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1317,12 +1317,18 @@ class NotificationTest(InvenTreeAPITestCase):
|
|||
|
||||
# Now, let's bulk delete all 'unread' notifications via the API,
|
||||
# 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
|
||||
self.assertEqual(NotificationMessage.objects.count(), 13)
|
||||
self.assertEqual(NotificationMessage.objects.filter(user=self.user).count(), 3)
|
||||
self.assertEqual(NotificationMessage.objects.count(), 17)
|
||||
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):
|
||||
"""Test that a simple notification can be created."""
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import part.models
|
|||
from data_exporter.mixins import DataExportViewMixin
|
||||
from InvenTree.api import ListCreateDestroyAPIView, ParameterListMixin, meta_path
|
||||
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 (
|
||||
ListCreateAPI,
|
||||
OutputOptionsMixin,
|
||||
|
|
@ -197,7 +197,7 @@ class ManufacturerPartList(
|
|||
"""
|
||||
|
||||
filterset_class = ManufacturerPartFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
output_options = ManufacturerOutputOptions
|
||||
|
||||
ordering_fields = ['part', 'IPN', 'MPN', 'manufacturer']
|
||||
|
|
@ -358,7 +358,7 @@ class SupplierPartList(
|
|||
"""
|
||||
|
||||
filterset_class = SupplierPartFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
output_options = SupplierPartOutputOptions
|
||||
|
||||
ordering_fields = [
|
||||
|
|
@ -472,7 +472,7 @@ class SupplierPriceBreakList(
|
|||
output_options = SupplierPriceBreakOutputOptions
|
||||
|
||||
filterset_class = SupplierPriceBreakFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
ordering_fields = ['quantity', 'supplier', 'SKU', 'price']
|
||||
|
||||
search_fields = ['part__SKU', 'part__supplier__name']
|
||||
|
|
|
|||
|
|
@ -73,6 +73,22 @@ class DataImportSessionMixin:
|
|||
serializer_class = importer.serializers.DataImportSessionSerializer
|
||||
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):
|
||||
"""API endpoint for accessing a list of DataImportSession objects."""
|
||||
|
|
|
|||
|
|
@ -174,6 +174,36 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
|
|||
# Check that there are new database records
|
||||
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):
|
||||
"""Tests for the admin interface integration."""
|
||||
|
|
|
|||
|
|
@ -35,11 +35,7 @@ from InvenTree.api import (
|
|||
meta_path,
|
||||
)
|
||||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||
from InvenTree.filters import (
|
||||
SEARCH_ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER_ALIAS,
|
||||
InvenTreeDateFilter,
|
||||
)
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER, InvenTreeDateFilter
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.helpers_model import construct_absolute_url, get_base_url
|
||||
from InvenTree.mixins import (
|
||||
|
|
@ -391,7 +387,7 @@ class PurchaseOrderList(
|
|||
"""
|
||||
|
||||
filterset_class = PurchaseOrderFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
output_options = PurchaseOrderOutputOptions
|
||||
|
||||
ordering_field_aliases = {
|
||||
|
|
@ -700,7 +696,7 @@ class PurchaseOrderLineItemList(
|
|||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_field_aliases = {
|
||||
'MPN': 'part__manufacturer_part__MPN',
|
||||
|
|
@ -859,7 +855,7 @@ class SalesOrderList(
|
|||
"""
|
||||
|
||||
filterset_class = SalesOrderFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
output_options = SalesOrderOutputOptions
|
||||
|
||||
ordering_field_aliases = {
|
||||
|
|
@ -1043,7 +1039,7 @@ class SalesOrderLineItemList(
|
|||
|
||||
filterset_class = SalesOrderLineItemFilter
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
output_options = SalesOrderLineItemOutputOptions
|
||||
|
||||
|
|
@ -1289,7 +1285,7 @@ class SalesOrderAllocationList(
|
|||
"""API endpoint for listing SalesOrderAllocation objects."""
|
||||
|
||||
filterset_class = SalesOrderAllocationFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
output_options = SalesOrderAllocationOutputOptions
|
||||
|
||||
ordering_fields = [
|
||||
|
|
@ -1399,7 +1395,7 @@ class SalesOrderShipmentList(SalesOrderShipmentMixin, ListCreateAPI):
|
|||
"""API list endpoint for SalesOrderShipment model."""
|
||||
|
||||
filterset_class = SalesOrderShipmentFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
ordering_fields = ['reference', 'delivery_date', 'shipment_date', 'allocated_items']
|
||||
|
||||
search_fields = [
|
||||
|
|
@ -1528,7 +1524,7 @@ class ReturnOrderList(
|
|||
"""API endpoint for accessing a list of ReturnOrder objects."""
|
||||
|
||||
filterset_class = ReturnOrderFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
output_options = ReturnOrderOutputOptions
|
||||
|
||||
|
|
@ -1674,7 +1670,7 @@ class ReturnOrderLineItemList(
|
|||
|
||||
filterset_class = ReturnOrderLineItemFilter
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
output_options = ReturnOrderLineItemOutputOptions
|
||||
|
||||
|
|
|
|||
|
|
@ -1863,12 +1863,14 @@ class PurchaseOrderLineItem(OrderLineItem):
|
|||
|
||||
verbose_name = _('Purchase Order Line Item')
|
||||
|
||||
# Filter for determining if a particular PurchaseOrderLineItem is overdue
|
||||
OVERDUE_FILTER = (
|
||||
Q(received__lt=F('quantity'))
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__lt=InvenTree.helpers.current_date())
|
||||
)
|
||||
@classmethod
|
||||
def get_overdue_filter(cls):
|
||||
"""Filter for determining if a particular PurchaseOrderLineItem is overdue."""
|
||||
return (
|
||||
Q(received__lt=F('quantity'))
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__lt=InvenTree.helpers.current_date())
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_api_url() -> str:
|
||||
|
|
@ -2067,12 +2069,14 @@ class SalesOrderLineItem(OrderLineItem):
|
|||
|
||||
verbose_name = _('Sales Order Line Item')
|
||||
|
||||
# Filter for determining if a particular SalesOrderLineItem is overdue
|
||||
OVERDUE_FILTER = (
|
||||
Q(shipped__lt=F('quantity'))
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__lt=InvenTree.helpers.current_date())
|
||||
)
|
||||
@classmethod
|
||||
def get_overdue_filter(cls):
|
||||
"""Filter for determining if a particular SalesOrderLineItem is overdue."""
|
||||
return (
|
||||
Q(shipped__lt=F('quantity'))
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__lt=InvenTree.helpers.current_date())
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
|
|
|
|||
|
|
@ -577,7 +577,7 @@ class PurchaseOrderLineItemSerializer(
|
|||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
order.models.PurchaseOrderLineItem.OVERDUE_FILTER,
|
||||
order.models.PurchaseOrderLineItem.get_overdue_filter(),
|
||||
then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField()),
|
||||
|
|
@ -1154,7 +1154,7 @@ class SalesOrderLineItemSerializer(
|
|||
overdue=Case(
|
||||
When(
|
||||
Q(order__status__in=SalesOrderStatusGroups.OPEN)
|
||||
& order.models.SalesOrderLineItem.OVERDUE_FILTER,
|
||||
& order.models.SalesOrderLineItem.get_overdue_filter(),
|
||||
then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField()),
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@ from InvenTree.api import (
|
|||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER,
|
||||
ORDER_FILTER_ALIAS,
|
||||
SEARCH_ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER_ALIAS,
|
||||
InvenTreeDateFilter,
|
||||
InvenTreeSearchFilter,
|
||||
NumberOrNullFilter,
|
||||
|
|
@ -75,20 +73,6 @@ class CategoryMixin:
|
|||
queryset = part_serializers.CategorySerializer.annotate_queryset(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):
|
||||
"""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.
|
||||
"""
|
||||
parent = str2bool(self.data.get('parent', None))
|
||||
parent = self.data.get('parent', None)
|
||||
top_level = str2bool(self.data.get('top_level', None))
|
||||
|
||||
# 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)."""
|
||||
# Clean up input data
|
||||
data = self.clean_data(request.data)
|
||||
response = super().update(request, *args, **kwargs)
|
||||
|
||||
if 'starred' in data:
|
||||
starred = str2bool(data.get('starred', False))
|
||||
|
||||
self.get_object().set_starred(request.user, starred, include_parents=False)
|
||||
|
||||
response = super().update(request, *args, **kwargs)
|
||||
|
||||
return response
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
|
|
@ -302,7 +285,7 @@ class CategoryTree(ListAPI):
|
|||
queryset = PartCategory.objects.all()
|
||||
serializer_class = part_serializers.CategoryTree
|
||||
|
||||
filter_backends = ORDER_FILTER_ALIAS
|
||||
filter_backends = ORDER_FILTER
|
||||
|
||||
ordering_fields = ['level', 'name', 'subcategories']
|
||||
|
||||
|
|
@ -1029,27 +1012,8 @@ class PartMixin(SerializerContextMixin):
|
|||
# Indicate that we can create a new Part via this endpoint
|
||||
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)
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extend serializer context data."""
|
||||
context = super().get_serializer_context()
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class PartOutputOptions(OutputConfiguration):
|
||||
"""Output options for Part endpoints."""
|
||||
|
|
@ -1080,7 +1044,7 @@ class PartList(
|
|||
filterset_class = PartFilter
|
||||
is_create = True
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_fields = [
|
||||
'name',
|
||||
|
|
@ -1134,6 +1098,7 @@ class PartDetail(PartMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
|
|||
"""
|
||||
# Clean input data
|
||||
data = self.clean_data(request.data)
|
||||
response = super().update(request, *args, **kwargs)
|
||||
|
||||
if 'starred' in data:
|
||||
starred = str2bool(data.get('starred', False))
|
||||
|
|
@ -1142,8 +1107,6 @@ class PartDetail(PartMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
|
|||
request.user, starred, include_variants=False, include_categories=False
|
||||
)
|
||||
|
||||
response = super().update(request, *args, **kwargs)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
|
@ -1442,7 +1405,7 @@ class BomList(
|
|||
|
||||
output_options = BomOutputOptions
|
||||
filterset_class = BomFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
search_fields = [
|
||||
'reference',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import os
|
|||
from django.conf import settings
|
||||
|
||||
import structlog
|
||||
from jinja2 import Environment, select_autoescape
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from common.settings import get_global_setting
|
||||
|
||||
|
|
@ -37,11 +37,7 @@ def compile_full_name_template(*args, **kwargs):
|
|||
# Cache the template string
|
||||
_part_full_name_template_string = template_string
|
||||
|
||||
env = Environment(
|
||||
autoescape=select_autoescape(default_for_string=False, default=False),
|
||||
variable_start_string='{{',
|
||||
variable_end_string='}}',
|
||||
)
|
||||
env = SandboxedEnvironment(variable_start_string='{{', variable_end_string='}}')
|
||||
|
||||
# Compile the template
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -129,7 +129,16 @@ class CategorySerializer(
|
|||
|
||||
def get_starred(self, category) -> bool:
|
||||
"""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(
|
||||
FilterableListField(
|
||||
|
|
@ -638,7 +647,6 @@ class PartSerializer(
|
|||
|
||||
- Allows us to optionally pass extra fields based on the query.
|
||||
"""
|
||||
self.starred_parts = kwargs.pop('starred_parts', [])
|
||||
create = kwargs.pop('create', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
|
@ -754,7 +762,16 @@ class PartSerializer(
|
|||
|
||||
def get_starred(self, part) -> bool:
|
||||
"""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
|
||||
category_detail = enable_filter(
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ class PartImageTestMixin:
|
|||
{'image': img_file},
|
||||
expected_code=200,
|
||||
)
|
||||
print(response.data)
|
||||
image_name = response.data['image']
|
||||
self.assertTrue(image_name.startswith('/media/part_images/part_image'))
|
||||
return image_name
|
||||
|
|
@ -111,7 +110,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
|||
url = reverse('api-part-category-list')
|
||||
|
||||
# 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
|
||||
for pk in [3, 4]:
|
||||
PartCategory.objects.get(pk=pk).set_starred(self.user, True)
|
||||
|
|
@ -812,9 +811,14 @@ class PartAPITest(PartAPITestBase):
|
|||
|
||||
# Children of PartCategory<1>, do not cascade
|
||||
response = self.get(url, {'parent': 1, 'cascade': 'false'})
|
||||
|
||||
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):
|
||||
"""Check that we can add categories."""
|
||||
data = {'name': 'Animals', 'description': 'All animals go here'}
|
||||
|
|
@ -1644,7 +1648,7 @@ class PartCreationTests(PartAPITestBase):
|
|||
|
||||
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(
|
||||
reverse('api-part-list'),
|
||||
{
|
||||
|
|
@ -1833,7 +1837,7 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
|
|||
|
||||
# Part should not have an image!
|
||||
with self.assertRaises(ValueError):
|
||||
print(p.image.file)
|
||||
_x = p.image.file
|
||||
|
||||
# Try to upload a non-image file
|
||||
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_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']
|
||||
self.assertEqual(in_stock, 126)
|
||||
self.assertEqual(data['unallocated_stock'], in_stock)
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ class PluginInstall(CreateAPI):
|
|||
|
||||
queryset = PluginConfig.objects.none()
|
||||
serializer_class = PluginSerializers.PluginConfigInstallSerializer
|
||||
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Install a plugin via the API."""
|
||||
|
|
|
|||
|
|
@ -113,9 +113,14 @@ class AppMixin:
|
|||
break
|
||||
|
||||
# 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:
|
||||
# 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
|
||||
# so that the import trick will work on reloading the same plugin
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
"""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.test import TestCase
|
||||
from django.urls import include, path, re_path
|
||||
|
|
@ -145,6 +148,56 @@ class AppMixinTest(BaseMixinDefinition, TestCase):
|
|||
"""Test that the sample plugin registers in settings."""
|
||||
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):
|
||||
"""Tests for NavigationMixin."""
|
||||
|
|
|
|||
|
|
@ -236,8 +236,8 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
|
|||
user: Optional user performing the installation
|
||||
version: Optional version specifier
|
||||
"""
|
||||
if user and not user.is_staff:
|
||||
raise ValidationError(_('Only staff users can administer plugins'))
|
||||
if user and not user.is_superuser:
|
||||
raise ValidationError(_('Only superuser accounts can administer plugins'))
|
||||
|
||||
if settings.PLUGINS_INSTALL_DISABLED:
|
||||
raise ValidationError(_('Plugin installation is disabled'))
|
||||
|
|
@ -269,6 +269,13 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
|
|||
if 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)
|
||||
|
||||
ret = {}
|
||||
|
|
@ -333,6 +340,9 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T
|
|||
"""
|
||||
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:
|
||||
raise ValidationError(_('Plugin uninstalling is disabled'))
|
||||
|
||||
|
|
|
|||
|
|
@ -473,12 +473,19 @@ class PluginsRegistry:
|
|||
|
||||
# Ensure that each loaded plugin has a valid configuration object in the database
|
||||
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
|
||||
if config.is_mandatory() and not config.active:
|
||||
config.active = True
|
||||
config.save(no_reload=True)
|
||||
try:
|
||||
config.save(no_reload=True)
|
||||
except (OperationalError, ProgrammingError):
|
||||
# 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:
|
||||
logger.exception('Unexpected error during plugin reload: %s', e)
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ class SampleValidatorPlugin(SettingsMixin, ValidationMixin, InvenTreePlugin):
|
|||
|
||||
if isinstance(instance, part.models.BomItem):
|
||||
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({
|
||||
'quantity': 'Bom item quantity must be an integer'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -69,8 +69,11 @@ class SampleSupplierTest(InvenTreeAPITestCase):
|
|||
self.assertEqual(len(res.data), 15)
|
||||
self.assertEqual(res.data[0]['sku'], 'BOLT-Steel-M5-5')
|
||||
|
||||
def test_import_part(self):
|
||||
"""Test importing a part by supplier."""
|
||||
def _disabled_test_import_part(self):
|
||||
"""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
|
||||
plugin = registry.get_plugin('samplesupplier', active=None)
|
||||
config = plugin.plugin_config()
|
||||
|
|
|
|||
|
|
@ -165,6 +165,9 @@ class PluginConfigInstallSerializer(serializers.Serializer):
|
|||
version = data.get('version', None)
|
||||
user = self.context['request'].user
|
||||
|
||||
if not user or not user.is_superuser:
|
||||
raise ValidationError(_('Only superuser accounts can administer plugins'))
|
||||
|
||||
return install_plugin(
|
||||
url=url, packagename=packagename, version=version, user=user
|
||||
)
|
||||
|
|
@ -266,10 +269,13 @@ class PluginUninstallSerializer(serializers.Serializer):
|
|||
"""Uninstall the specified 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(
|
||||
instance,
|
||||
user=self.context['request'].user,
|
||||
delete_config=validated_data.get('delete_config', True),
|
||||
instance, user=user, delete_config=validated_data.get('delete_config', True)
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,21 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
|||
"""Test the plugin install command."""
|
||||
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
|
||||
data = self.post(
|
||||
url,
|
||||
|
|
@ -209,7 +224,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
|||
test_plg.refresh_from_db()
|
||||
self.assertTrue(test_plg.is_active())
|
||||
|
||||
def test_pluginCfg_delete(self):
|
||||
def test_plugin_config_delete(self):
|
||||
"""Test deleting a config."""
|
||||
test_plg = self.plugin_confs.first()
|
||||
assert test_plg is not None
|
||||
|
|
|
|||
|
|
@ -11,11 +11,14 @@ from typing import Optional
|
|||
from unittest import mock
|
||||
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
|
||||
|
||||
import plugin.templatetags.plugin_extras as plugin_tags
|
||||
from InvenTree.unit_test import PluginRegistryMixin, TestQueryMixin
|
||||
from plugin import InvenTreePlugin, PluginMixinEnum
|
||||
from plugin.installer import install_plugin
|
||||
from plugin.registry import registry
|
||||
from plugin.samples.integration.another_sample import (
|
||||
NoIntegrationPlugin,
|
||||
|
|
@ -278,6 +281,9 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase):
|
|||
|
||||
def test_broken_samples(self):
|
||||
"""Test that the broken samples trigger reloads."""
|
||||
# Reset the registry to a known state
|
||||
registry.errors = {}
|
||||
|
||||
# In the base setup there are no errors
|
||||
self.assertEqual(len(registry.errors), 0)
|
||||
|
||||
|
|
@ -638,3 +644,40 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase):
|
|||
self.assertTrue(cfg.is_builtin())
|
||||
self.assertFalse(cfg.is_package())
|
||||
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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from django.apps import AppConfig
|
|||
from django.core.exceptions import AppRegistryNotReady, ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import transaction
|
||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
|
||||
import structlog
|
||||
|
|
@ -160,9 +161,10 @@ class ReportConfig(AppConfig):
|
|||
# Otherwise, create a new entry
|
||||
try:
|
||||
# Create a new entry
|
||||
report.models.LabelTemplate.objects.create(
|
||||
**template, template=self.file_from_template('label', filename)
|
||||
)
|
||||
with transaction.atomic():
|
||||
report.models.LabelTemplate.objects.create(
|
||||
**template, template=self.file_from_template('label', filename)
|
||||
)
|
||||
logger.info("Creating new label template: '%s'", template['name'])
|
||||
except ValidationError:
|
||||
logger.warning(
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import logging
|
|||
import os
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from django import template
|
||||
from django.apps.registry import apps
|
||||
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.query import QuerySet
|
||||
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)
|
||||
|
||||
|
||||
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()
|
||||
def asset(filename):
|
||||
"""Return fully-qualified path for an upload report asset file.
|
||||
|
|
@ -159,18 +268,21 @@ def asset(filename):
|
|||
# Prepend an empty string to enforce 'stringiness'
|
||||
filename = '' + filename
|
||||
|
||||
# If in debug mode, return URL to the image, not a local file
|
||||
debug_mode = get_global_setting('REPORT_DEBUG_MODE', cache=False)
|
||||
# Remove any leading slash characters from the filename, to prevent path traversal attacks
|
||||
filename = str(filename).lstrip('/\\')
|
||||
|
||||
# Test if the file actually exists
|
||||
full_path = settings.MEDIA_ROOT.joinpath('report', 'assets', filename).resolve()
|
||||
full_path = Path('report', 'assets', filename)
|
||||
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise FileNotFoundError(_('Asset file does not exist') + f": '{filename}'")
|
||||
if not media_file_exists(full_path):
|
||||
raise FileNotFoundError(_('Asset file not found') + f": '{filename}'")
|
||||
|
||||
if debug_mode:
|
||||
return os.path.join(settings.MEDIA_URL, 'report', 'assets', filename)
|
||||
return f'file://{full_path}'
|
||||
# In debug mode, return a web URL to the asset file (rather than a local file path)
|
||||
if get_global_setting('REPORT_DEBUG_MODE', cache=False):
|
||||
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()
|
||||
|
|
@ -187,7 +299,7 @@ def uploaded_image(
|
|||
"""Return raw image data from an 'uploaded' image.
|
||||
|
||||
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)
|
||||
replacement_file: The filename of the placeholder image (default = 'blank_image.png')
|
||||
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'
|
||||
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
|
||||
debug_mode = get_global_setting('REPORT_DEBUG_MODE', cache=False)
|
||||
|
||||
# Check if the file exists
|
||||
if not 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
|
||||
# Load image data - this will check if the file exists
|
||||
exists = bool(filename) and media_file_exists(filename)
|
||||
|
||||
if not exists and not replace_missing:
|
||||
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:
|
||||
# In debug mode, return a web path (rather than an encoded image blob)
|
||||
if exists:
|
||||
return os.path.join(settings.MEDIA_URL, filename)
|
||||
return os.path.join(settings.STATIC_URL, 'img', replacement_file)
|
||||
|
||||
elif not exists:
|
||||
full_path = settings.STATIC_ROOT.joinpath('img', replacement_file).resolve()
|
||||
|
||||
# Load the image, check that it is valid
|
||||
if full_path.exists() and full_path.is_file():
|
||||
img = Image.open(full_path)
|
||||
if img_data:
|
||||
img = Image.open(BytesIO(img_data))
|
||||
else:
|
||||
# A placeholder image showing that the image is missing
|
||||
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'
|
||||
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:
|
||||
exists = False
|
||||
else:
|
||||
try:
|
||||
full_path = settings.MEDIA_ROOT.joinpath(filename).resolve()
|
||||
exists = full_path.exists() and full_path.is_file()
|
||||
except Exception:
|
||||
exists = False
|
||||
raise FileNotFoundError(_('No image file specified'))
|
||||
|
||||
if not exists:
|
||||
raise FileNotFoundError(_('Image file not found') + f": '{filename}'")
|
||||
|
||||
# Read the file data
|
||||
with open(full_path, 'rb') as f:
|
||||
data = f.read()
|
||||
# Read out the file contents
|
||||
# Note: This will check if the file exists, and raise an error if it does not
|
||||
data = get_media_file_contents(filename)
|
||||
|
||||
# Return the base64-encoded data
|
||||
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:
|
||||
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'))
|
||||
|
||||
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(
|
||||
InvenTree.helpers.image2name(part.image, preview, thumbnail), **kwargs
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ from djmoney.money import Money
|
|||
from PIL import Image
|
||||
|
||||
from common.models import InvenTreeSetting, Parameter, ParameterTemplate
|
||||
from InvenTree.config import get_testfolder_dir
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
from part.models import Part # TODO fix import: PartParameter, PartParameterTemplate
|
||||
from part.test_api import PartImageTestMixin
|
||||
|
|
@ -81,7 +80,27 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||
|
||||
self.debug_mode(False)
|
||||
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):
|
||||
"""Tests for retrieving uploaded images."""
|
||||
|
|
@ -148,6 +167,10 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||
)
|
||||
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):
|
||||
"""Unit tests for the 'part_image' tag."""
|
||||
with self.assertRaises(TypeError):
|
||||
|
|
@ -157,8 +180,10 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||
self.create_test_image()
|
||||
obj.refresh_from_db()
|
||||
|
||||
report_tags.part_image(obj, preview=True)
|
||||
report_tags.part_image(obj, thumbnail=True)
|
||||
r = report_tags.part_image(obj, preview=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):
|
||||
"""Unit tests for the 'company_image' tag."""
|
||||
|
|
@ -392,12 +417,16 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||
def test_encode_svg_image(self):
|
||||
"""Test the encode_svg_image template tag."""
|
||||
# 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:
|
||||
f.write('<svg xmlns="http://www.w3.org/2000/svg>')
|
||||
|
||||
# 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.assertIn('svg', svg)
|
||||
self.assertEqual(
|
||||
|
|
|
|||
|
|
@ -571,7 +571,9 @@ class TestReportTest(PrintTestMixins, ReportTest):
|
|||
|
||||
def test_mdl_salesorder(self):
|
||||
"""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):
|
||||
|
|
|
|||
|
|
@ -37,9 +37,8 @@ from InvenTree.api import (
|
|||
)
|
||||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER_ALIAS,
|
||||
ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER_ALIAS,
|
||||
InvenTreeDateFilter,
|
||||
NumberOrNullFilter,
|
||||
)
|
||||
|
|
@ -455,7 +454,7 @@ class StockLocationTree(ListAPI):
|
|||
queryset = StockLocation.objects.all()
|
||||
serializer_class = StockSerializers.LocationTreeSerializer
|
||||
|
||||
filter_backends = ORDER_FILTER_ALIAS
|
||||
filter_backends = ORDER_FILTER
|
||||
|
||||
ordering_fields = ['level', 'name', 'sublocations']
|
||||
|
||||
|
|
@ -686,8 +685,8 @@ class StockFilter(FilterSet):
|
|||
return queryset
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(StockItem.EXPIRED_FILTER)
|
||||
return queryset.exclude(StockItem.EXPIRED_FILTER)
|
||||
return queryset.filter(StockItem.get_expired_filter())
|
||||
return queryset.exclude(StockItem.get_expired_filter())
|
||||
|
||||
external = rest_filters.BooleanFilter(
|
||||
label=_('External Location'), method='filter_external'
|
||||
|
|
@ -1245,14 +1244,19 @@ class StockList(
|
|||
else:
|
||||
# Create a single StockItem object
|
||||
# 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):
|
||||
item.set_status(status_value)
|
||||
|
||||
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(
|
||||
response_data,
|
||||
|
|
@ -1260,7 +1264,7 @@ class StockList(
|
|||
headers=self.get_success_headers(serializer.data),
|
||||
)
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_field_aliases = {
|
||||
'part': 'part__name',
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ from inspect import signature
|
|||
from typing import Optional
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from jinja2 import Template
|
||||
from django.template import Context, Template
|
||||
|
||||
import common.models
|
||||
import InvenTree.exceptions
|
||||
|
|
@ -76,7 +75,7 @@ def generate_batch_code(**kwargs):
|
|||
'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]:
|
||||
|
|
|
|||
|
|
@ -10,24 +10,40 @@ def add_part_links(apps, schema_editor):
|
|||
|
||||
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
|
||||
|
||||
# No item link - skip
|
||||
if item is None:
|
||||
continue
|
||||
|
||||
part = item.part
|
||||
|
||||
# No part link - skip
|
||||
if part is None:
|
||||
continue
|
||||
|
||||
# Already linked to the correct part - skip
|
||||
if tracking.part == part:
|
||||
continue
|
||||
|
||||
tracking.part = part
|
||||
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:
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -518,12 +518,14 @@ class StockItem(
|
|||
status__in=StockStatusGroups.AVAILABLE_CODES,
|
||||
)
|
||||
|
||||
# A query filter which can be used to filter StockItem objects which have expired
|
||||
EXPIRED_FILTER = (
|
||||
IN_STOCK_FILTER
|
||||
& ~Q(expiry_date=None)
|
||||
& Q(expiry_date__lt=InvenTree.helpers.current_date())
|
||||
)
|
||||
@classmethod
|
||||
def get_expired_filter(cls):
|
||||
"""A query filter which can be used to filter StockItem objects which have expired."""
|
||||
return (
|
||||
cls.IN_STOCK_FILTER
|
||||
& ~Q(expiry_date=None)
|
||||
& Q(expiry_date__lt=InvenTree.helpers.current_date())
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _create_serial_numbers(cls, serials: list, **kwargs) -> QuerySet:
|
||||
|
|
|
|||
|
|
@ -516,7 +516,7 @@ class StockItemSerializer(
|
|||
queryset = queryset.annotate(
|
||||
expired=Case(
|
||||
When(
|
||||
StockItem.EXPIRED_FILTER,
|
||||
StockItem.get_expired_filter(),
|
||||
then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField()),
|
||||
|
|
|
|||
|
|
@ -575,6 +575,81 @@ class StockItemListTest(StockAPITestCase):
|
|||
for ordering in ['part', 'location', 'stock', 'status', 'IPN', 'MPN', 'SKU']:
|
||||
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):
|
||||
"""Test filtering against "top level" stock location."""
|
||||
# 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
|
||||
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:
|
||||
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."""
|
||||
# The part 'R_4K7_0603' (pk=4) has a default location specified
|
||||
|
||||
# Create a new StockItem instance
|
||||
response = self.post(
|
||||
self.list_url, data={'part': 4, 'quantity': 10}, expected_code=201
|
||||
)
|
||||
|
||||
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?
|
||||
|
||||
response = self.post(
|
||||
|
|
@ -2323,17 +2407,7 @@ class StockTestResultTest(StockAPITestCase):
|
|||
self.delete(url, {}, expected_code=400)
|
||||
|
||||
# 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, '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
|
||||
)
|
||||
response = self.delete(url, {'items': tests}, expected_code=200)
|
||||
|
||||
self.assertEqual(StockItemTestResult.objects.count(), n)
|
||||
|
||||
|
|
|
|||
|
|
@ -367,49 +367,44 @@ class GetAuthToken(GenericAPIView):
|
|||
- Existing tokens are *never* exposed again via the API
|
||||
- Once the token is provided, it can be used for auth until it expires
|
||||
"""
|
||||
if request.user.is_authenticated:
|
||||
user = request.user
|
||||
name = request.query_params.get('name', '')
|
||||
if not request.user.is_authenticated:
|
||||
raise exceptions.NotAuthenticated() # pragma: no cover
|
||||
|
||||
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
|
||||
token = ApiToken.objects.filter(
|
||||
user=user, name=name, revoked=False, expiry__gte=today
|
||||
).first()
|
||||
today = datetime.date.today()
|
||||
|
||||
if not token:
|
||||
# User is authenticated, and requesting a token against the provided name.
|
||||
token = ApiToken.objects.create(user=request.user, name=name)
|
||||
# Find existing token, which has not expired
|
||||
token = ApiToken.objects.filter(
|
||||
user=user, name=name, revoked=False, expiry__gte=today
|
||||
).first()
|
||||
|
||||
logger.info(
|
||||
"Created new API token for user '%s' (name='%s')",
|
||||
user.username,
|
||||
name,
|
||||
)
|
||||
if not token:
|
||||
# User is authenticated, and requesting a token against the provided name.
|
||||
token = ApiToken.objects.create(user=request.user, name=name)
|
||||
|
||||
logger.info(
|
||||
"Created new API token for user '%s' (name='%s')", user.username, name
|
||||
)
|
||||
|
||||
# Add some metadata about the request
|
||||
token.set_metadata('user_agent', request.headers.get('user-agent', ''))
|
||||
token.set_metadata('remote_addr', request.META.get('REMOTE_ADDR', ''))
|
||||
token.set_metadata('remote_host', request.META.get('REMOTE_HOST', ''))
|
||||
token.set_metadata('remote_user', request.META.get('REMOTE_USER', ''))
|
||||
token.set_metadata('server_name', request.META.get('SERVER_NAME', ''))
|
||||
token.set_metadata('server_port', request.META.get('SERVER_PORT', ''))
|
||||
token.set_metadata('user_agent', request.headers.get('user-agent', ''))
|
||||
token.set_metadata('remote_addr', request.META.get('REMOTE_ADDR', ''))
|
||||
token.set_metadata('remote_host', request.META.get('REMOTE_HOST', ''))
|
||||
token.set_metadata('remote_user', request.META.get('REMOTE_USER', ''))
|
||||
token.set_metadata('server_name', request.META.get('SERVER_NAME', ''))
|
||||
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
|
||||
if not get_user(request).is_authenticated:
|
||||
login(
|
||||
request, user, backend='django.contrib.auth.backends.ModelBackend'
|
||||
)
|
||||
# Ensure that the users session is logged in
|
||||
if not get_user(request).is_authenticated:
|
||||
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
|
||||
|
||||
return Response(data)
|
||||
|
||||
else:
|
||||
raise exceptions.NotAuthenticated() # pragma: no cover
|
||||
return Response(data)
|
||||
|
||||
|
||||
class TokenMixin:
|
||||
|
|
|
|||
|
|
@ -238,8 +238,21 @@ class ApiTokenSerializer(InvenTreeModelSerializer):
|
|||
|
||||
def validate(self, data):
|
||||
"""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:
|
||||
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)
|
||||
|
||||
user_detail = UserSerializer(source='user', read_only=True)
|
||||
|
|
@ -380,6 +393,9 @@ class MeUserSerializer(ExtendedUserSerializer):
|
|||
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 = [
|
||||
*ExtendedUserSerializer.Meta.read_only_fields,
|
||||
'is_active',
|
||||
|
|
@ -389,6 +405,28 @@ class MeUserSerializer(ExtendedUserSerializer):
|
|||
|
||||
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):
|
||||
"""Generate a random password of given length."""
|
||||
|
|
|
|||
|
|
@ -258,6 +258,8 @@ class SuperuserAPITests(InvenTreeAPITestCase):
|
|||
class UserTokenTests(InvenTreeAPITestCase):
|
||||
"""Tests for user token functionality."""
|
||||
|
||||
fixtures = ['users']
|
||||
|
||||
def test_token_generation(self):
|
||||
"""Test user token generation."""
|
||||
url = reverse('api-token')
|
||||
|
|
@ -396,6 +398,28 @@ class UserTokenTests(InvenTreeAPITestCase):
|
|||
self.client.logout()
|
||||
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):
|
||||
"""Tests for the GroupDetail API endpoint."""
|
||||
|
|
|
|||
|
|
@ -433,56 +433,56 @@ charset-normalizer==3.4.4 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
cryptography==46.0.5 \
|
||||
--hash=sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72 \
|
||||
--hash=sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235 \
|
||||
--hash=sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9 \
|
||||
--hash=sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356 \
|
||||
--hash=sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257 \
|
||||
--hash=sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad \
|
||||
--hash=sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4 \
|
||||
--hash=sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c \
|
||||
--hash=sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614 \
|
||||
--hash=sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed \
|
||||
--hash=sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31 \
|
||||
--hash=sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229 \
|
||||
--hash=sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0 \
|
||||
--hash=sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731 \
|
||||
--hash=sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b \
|
||||
--hash=sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4 \
|
||||
--hash=sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4 \
|
||||
--hash=sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263 \
|
||||
--hash=sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595 \
|
||||
--hash=sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1 \
|
||||
--hash=sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678 \
|
||||
--hash=sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48 \
|
||||
--hash=sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76 \
|
||||
--hash=sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0 \
|
||||
--hash=sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18 \
|
||||
--hash=sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d \
|
||||
--hash=sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d \
|
||||
--hash=sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1 \
|
||||
--hash=sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981 \
|
||||
--hash=sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7 \
|
||||
--hash=sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82 \
|
||||
--hash=sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2 \
|
||||
--hash=sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4 \
|
||||
--hash=sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663 \
|
||||
--hash=sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c \
|
||||
--hash=sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d \
|
||||
--hash=sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a \
|
||||
--hash=sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a \
|
||||
--hash=sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d \
|
||||
--hash=sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b \
|
||||
--hash=sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a \
|
||||
--hash=sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826 \
|
||||
--hash=sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee \
|
||||
--hash=sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9 \
|
||||
--hash=sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648 \
|
||||
--hash=sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da \
|
||||
--hash=sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2 \
|
||||
--hash=sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2 \
|
||||
--hash=sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87
|
||||
cryptography==46.0.6 \
|
||||
--hash=sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70 \
|
||||
--hash=sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d \
|
||||
--hash=sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a \
|
||||
--hash=sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0 \
|
||||
--hash=sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97 \
|
||||
--hash=sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30 \
|
||||
--hash=sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759 \
|
||||
--hash=sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c \
|
||||
--hash=sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead \
|
||||
--hash=sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275 \
|
||||
--hash=sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58 \
|
||||
--hash=sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f \
|
||||
--hash=sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361 \
|
||||
--hash=sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507 \
|
||||
--hash=sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa \
|
||||
--hash=sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b \
|
||||
--hash=sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b \
|
||||
--hash=sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8 \
|
||||
--hash=sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8 \
|
||||
--hash=sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72 \
|
||||
--hash=sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175 \
|
||||
--hash=sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e \
|
||||
--hash=sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124 \
|
||||
--hash=sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a \
|
||||
--hash=sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c \
|
||||
--hash=sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f \
|
||||
--hash=sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d \
|
||||
--hash=sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4 \
|
||||
--hash=sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c \
|
||||
--hash=sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290 \
|
||||
--hash=sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca \
|
||||
--hash=sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d \
|
||||
--hash=sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a \
|
||||
--hash=sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed \
|
||||
--hash=sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a \
|
||||
--hash=sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb \
|
||||
--hash=sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8 \
|
||||
--hash=sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707 \
|
||||
--hash=sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410 \
|
||||
--hash=sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736 \
|
||||
--hash=sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2 \
|
||||
--hash=sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4 \
|
||||
--hash=sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013 \
|
||||
--hash=sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19 \
|
||||
--hash=sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b \
|
||||
--hash=sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738 \
|
||||
--hash=sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463 \
|
||||
--hash=sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77 \
|
||||
--hash=sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
@ -1659,9 +1659,9 @@ pynacl==1.6.2 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# paramiko
|
||||
pypdf==6.6.2 \
|
||||
--hash=sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016 \
|
||||
--hash=sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba
|
||||
pypdf==6.7.5 \
|
||||
--hash=sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13 \
|
||||
--hash=sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
@ -1902,9 +1902,9 @@ referencing==0.37.0 \
|
|||
# -c src/backend/requirements.txt
|
||||
# jsonschema
|
||||
# jsonschema-specifications
|
||||
requests==2.32.5 \
|
||||
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
|
||||
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
|
||||
requests==2.33.0 \
|
||||
--hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
|
||||
--hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# django-allauth
|
||||
|
|
|
|||
|
|
@ -329,56 +329,56 @@ coverage[toml]==7.13.1 \
|
|||
--hash=sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7 \
|
||||
--hash=sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766
|
||||
# via -r src/backend/requirements-dev.in
|
||||
cryptography==46.0.5 \
|
||||
--hash=sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72 \
|
||||
--hash=sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235 \
|
||||
--hash=sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9 \
|
||||
--hash=sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356 \
|
||||
--hash=sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257 \
|
||||
--hash=sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad \
|
||||
--hash=sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4 \
|
||||
--hash=sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c \
|
||||
--hash=sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614 \
|
||||
--hash=sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed \
|
||||
--hash=sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31 \
|
||||
--hash=sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229 \
|
||||
--hash=sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0 \
|
||||
--hash=sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731 \
|
||||
--hash=sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b \
|
||||
--hash=sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4 \
|
||||
--hash=sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4 \
|
||||
--hash=sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263 \
|
||||
--hash=sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595 \
|
||||
--hash=sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1 \
|
||||
--hash=sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678 \
|
||||
--hash=sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48 \
|
||||
--hash=sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76 \
|
||||
--hash=sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0 \
|
||||
--hash=sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18 \
|
||||
--hash=sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d \
|
||||
--hash=sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d \
|
||||
--hash=sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1 \
|
||||
--hash=sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981 \
|
||||
--hash=sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7 \
|
||||
--hash=sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82 \
|
||||
--hash=sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2 \
|
||||
--hash=sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4 \
|
||||
--hash=sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663 \
|
||||
--hash=sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c \
|
||||
--hash=sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d \
|
||||
--hash=sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a \
|
||||
--hash=sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a \
|
||||
--hash=sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d \
|
||||
--hash=sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b \
|
||||
--hash=sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a \
|
||||
--hash=sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826 \
|
||||
--hash=sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee \
|
||||
--hash=sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9 \
|
||||
--hash=sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648 \
|
||||
--hash=sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da \
|
||||
--hash=sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2 \
|
||||
--hash=sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2 \
|
||||
--hash=sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87
|
||||
cryptography==46.0.6 \
|
||||
--hash=sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70 \
|
||||
--hash=sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d \
|
||||
--hash=sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a \
|
||||
--hash=sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0 \
|
||||
--hash=sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97 \
|
||||
--hash=sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30 \
|
||||
--hash=sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759 \
|
||||
--hash=sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c \
|
||||
--hash=sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead \
|
||||
--hash=sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275 \
|
||||
--hash=sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58 \
|
||||
--hash=sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f \
|
||||
--hash=sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361 \
|
||||
--hash=sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507 \
|
||||
--hash=sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa \
|
||||
--hash=sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b \
|
||||
--hash=sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b \
|
||||
--hash=sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8 \
|
||||
--hash=sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8 \
|
||||
--hash=sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72 \
|
||||
--hash=sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175 \
|
||||
--hash=sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e \
|
||||
--hash=sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124 \
|
||||
--hash=sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a \
|
||||
--hash=sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c \
|
||||
--hash=sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f \
|
||||
--hash=sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d \
|
||||
--hash=sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4 \
|
||||
--hash=sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c \
|
||||
--hash=sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290 \
|
||||
--hash=sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca \
|
||||
--hash=sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d \
|
||||
--hash=sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a \
|
||||
--hash=sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed \
|
||||
--hash=sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a \
|
||||
--hash=sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb \
|
||||
--hash=sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8 \
|
||||
--hash=sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707 \
|
||||
--hash=sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410 \
|
||||
--hash=sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736 \
|
||||
--hash=sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2 \
|
||||
--hash=sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4 \
|
||||
--hash=sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013 \
|
||||
--hash=sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19 \
|
||||
--hash=sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b \
|
||||
--hash=sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738 \
|
||||
--hash=sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463 \
|
||||
--hash=sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77 \
|
||||
--hash=sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4
|
||||
# via
|
||||
# -c src/backend/requirements-3.14.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.txt
|
||||
# pre-commit
|
||||
requests==2.32.5 \
|
||||
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
|
||||
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
|
||||
requests==2.33.0 \
|
||||
--hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
|
||||
--hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
|
||||
# via
|
||||
# -c src/backend/requirements-3.14.txt
|
||||
# -c src/backend/requirements.txt
|
||||
|
|
|
|||
|
|
@ -325,56 +325,56 @@ coverage[toml]==7.13.1 \
|
|||
--hash=sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7 \
|
||||
--hash=sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766
|
||||
# via -r src/backend/requirements-dev.in
|
||||
cryptography==46.0.5 \
|
||||
--hash=sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72 \
|
||||
--hash=sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235 \
|
||||
--hash=sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9 \
|
||||
--hash=sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356 \
|
||||
--hash=sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257 \
|
||||
--hash=sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad \
|
||||
--hash=sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4 \
|
||||
--hash=sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c \
|
||||
--hash=sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614 \
|
||||
--hash=sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed \
|
||||
--hash=sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31 \
|
||||
--hash=sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229 \
|
||||
--hash=sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0 \
|
||||
--hash=sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731 \
|
||||
--hash=sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b \
|
||||
--hash=sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4 \
|
||||
--hash=sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4 \
|
||||
--hash=sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263 \
|
||||
--hash=sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595 \
|
||||
--hash=sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1 \
|
||||
--hash=sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678 \
|
||||
--hash=sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48 \
|
||||
--hash=sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76 \
|
||||
--hash=sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0 \
|
||||
--hash=sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18 \
|
||||
--hash=sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d \
|
||||
--hash=sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d \
|
||||
--hash=sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1 \
|
||||
--hash=sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981 \
|
||||
--hash=sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7 \
|
||||
--hash=sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82 \
|
||||
--hash=sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2 \
|
||||
--hash=sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4 \
|
||||
--hash=sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663 \
|
||||
--hash=sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c \
|
||||
--hash=sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d \
|
||||
--hash=sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a \
|
||||
--hash=sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a \
|
||||
--hash=sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d \
|
||||
--hash=sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b \
|
||||
--hash=sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a \
|
||||
--hash=sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826 \
|
||||
--hash=sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee \
|
||||
--hash=sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9 \
|
||||
--hash=sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648 \
|
||||
--hash=sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da \
|
||||
--hash=sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2 \
|
||||
--hash=sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2 \
|
||||
--hash=sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87
|
||||
cryptography==46.0.6 \
|
||||
--hash=sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70 \
|
||||
--hash=sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d \
|
||||
--hash=sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a \
|
||||
--hash=sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0 \
|
||||
--hash=sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97 \
|
||||
--hash=sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30 \
|
||||
--hash=sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759 \
|
||||
--hash=sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c \
|
||||
--hash=sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead \
|
||||
--hash=sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275 \
|
||||
--hash=sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58 \
|
||||
--hash=sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f \
|
||||
--hash=sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361 \
|
||||
--hash=sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507 \
|
||||
--hash=sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa \
|
||||
--hash=sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b \
|
||||
--hash=sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b \
|
||||
--hash=sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8 \
|
||||
--hash=sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8 \
|
||||
--hash=sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72 \
|
||||
--hash=sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175 \
|
||||
--hash=sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e \
|
||||
--hash=sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124 \
|
||||
--hash=sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a \
|
||||
--hash=sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c \
|
||||
--hash=sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f \
|
||||
--hash=sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d \
|
||||
--hash=sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4 \
|
||||
--hash=sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c \
|
||||
--hash=sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290 \
|
||||
--hash=sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca \
|
||||
--hash=sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d \
|
||||
--hash=sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a \
|
||||
--hash=sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed \
|
||||
--hash=sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a \
|
||||
--hash=sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb \
|
||||
--hash=sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8 \
|
||||
--hash=sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707 \
|
||||
--hash=sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410 \
|
||||
--hash=sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736 \
|
||||
--hash=sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2 \
|
||||
--hash=sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4 \
|
||||
--hash=sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013 \
|
||||
--hash=sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19 \
|
||||
--hash=sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b \
|
||||
--hash=sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738 \
|
||||
--hash=sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463 \
|
||||
--hash=sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77 \
|
||||
--hash=sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# pdfminer-six
|
||||
|
|
@ -612,9 +612,9 @@ pyyaml==6.0.3 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# pre-commit
|
||||
requests==2.32.5 \
|
||||
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
|
||||
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
|
||||
requests==2.33.0 \
|
||||
--hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
|
||||
--hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests-mock
|
||||
|
|
|
|||
|
|
@ -419,56 +419,56 @@ charset-normalizer==3.4.4 \
|
|||
--hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \
|
||||
--hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608
|
||||
# via requests
|
||||
cryptography==46.0.5 \
|
||||
--hash=sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72 \
|
||||
--hash=sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235 \
|
||||
--hash=sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9 \
|
||||
--hash=sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356 \
|
||||
--hash=sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257 \
|
||||
--hash=sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad \
|
||||
--hash=sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4 \
|
||||
--hash=sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c \
|
||||
--hash=sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614 \
|
||||
--hash=sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed \
|
||||
--hash=sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31 \
|
||||
--hash=sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229 \
|
||||
--hash=sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0 \
|
||||
--hash=sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731 \
|
||||
--hash=sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b \
|
||||
--hash=sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4 \
|
||||
--hash=sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4 \
|
||||
--hash=sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263 \
|
||||
--hash=sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595 \
|
||||
--hash=sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1 \
|
||||
--hash=sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678 \
|
||||
--hash=sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48 \
|
||||
--hash=sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76 \
|
||||
--hash=sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0 \
|
||||
--hash=sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18 \
|
||||
--hash=sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d \
|
||||
--hash=sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d \
|
||||
--hash=sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1 \
|
||||
--hash=sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981 \
|
||||
--hash=sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7 \
|
||||
--hash=sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82 \
|
||||
--hash=sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2 \
|
||||
--hash=sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4 \
|
||||
--hash=sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663 \
|
||||
--hash=sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c \
|
||||
--hash=sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d \
|
||||
--hash=sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a \
|
||||
--hash=sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a \
|
||||
--hash=sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d \
|
||||
--hash=sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b \
|
||||
--hash=sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a \
|
||||
--hash=sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826 \
|
||||
--hash=sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee \
|
||||
--hash=sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9 \
|
||||
--hash=sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648 \
|
||||
--hash=sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da \
|
||||
--hash=sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2 \
|
||||
--hash=sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2 \
|
||||
--hash=sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87
|
||||
cryptography==46.0.6 \
|
||||
--hash=sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70 \
|
||||
--hash=sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d \
|
||||
--hash=sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a \
|
||||
--hash=sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0 \
|
||||
--hash=sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97 \
|
||||
--hash=sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30 \
|
||||
--hash=sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759 \
|
||||
--hash=sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c \
|
||||
--hash=sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead \
|
||||
--hash=sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275 \
|
||||
--hash=sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58 \
|
||||
--hash=sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f \
|
||||
--hash=sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361 \
|
||||
--hash=sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507 \
|
||||
--hash=sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa \
|
||||
--hash=sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b \
|
||||
--hash=sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b \
|
||||
--hash=sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8 \
|
||||
--hash=sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8 \
|
||||
--hash=sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72 \
|
||||
--hash=sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175 \
|
||||
--hash=sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e \
|
||||
--hash=sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124 \
|
||||
--hash=sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a \
|
||||
--hash=sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c \
|
||||
--hash=sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f \
|
||||
--hash=sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d \
|
||||
--hash=sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4 \
|
||||
--hash=sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c \
|
||||
--hash=sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290 \
|
||||
--hash=sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca \
|
||||
--hash=sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d \
|
||||
--hash=sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a \
|
||||
--hash=sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed \
|
||||
--hash=sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a \
|
||||
--hash=sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb \
|
||||
--hash=sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8 \
|
||||
--hash=sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707 \
|
||||
--hash=sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410 \
|
||||
--hash=sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736 \
|
||||
--hash=sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2 \
|
||||
--hash=sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4 \
|
||||
--hash=sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013 \
|
||||
--hash=sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19 \
|
||||
--hash=sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b \
|
||||
--hash=sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738 \
|
||||
--hash=sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463 \
|
||||
--hash=sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77 \
|
||||
--hash=sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4
|
||||
# via
|
||||
# -r src/backend/requirements.in
|
||||
# django-anymail
|
||||
|
|
@ -1467,9 +1467,9 @@ pynacl==1.6.2 \
|
|||
--hash=sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2 \
|
||||
--hash=sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9
|
||||
# via paramiko
|
||||
pypdf==6.6.2 \
|
||||
--hash=sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016 \
|
||||
--hash=sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba
|
||||
pypdf==6.7.5 \
|
||||
--hash=sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13 \
|
||||
--hash=sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d
|
||||
# via -r src/backend/requirements.in
|
||||
pyphen==0.17.2 \
|
||||
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
|
||||
|
|
@ -1686,9 +1686,9 @@ referencing==0.37.0 \
|
|||
# via
|
||||
# jsonschema
|
||||
# jsonschema-specifications
|
||||
requests==2.32.5 \
|
||||
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
|
||||
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
|
||||
requests==2.33.0 \
|
||||
--hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
|
||||
--hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
|
||||
# via
|
||||
# django-allauth
|
||||
# django-anymail
|
||||
|
|
|
|||
|
|
@ -48,13 +48,22 @@ export default defineConfig({
|
|||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome']
|
||||
}
|
||||
},
|
||||
testIgnore: /customization/ // Ignore all tests in the "customization" folder for this project
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...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
|
||||
}
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ export const queryClient = new QueryClient({
|
|||
}
|
||||
});
|
||||
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 traceid = `00-${runID}-${frontendID}-01`;
|
||||
api.defaults.headers['traceparent'] = traceid;
|
||||
|
|
|
|||
|
|
@ -112,9 +112,9 @@ export const PdfPreviewComponent: PreviewAreaComponent = forwardRef(
|
|||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
width: '100%',
|
||||
paddingTop: '50px'
|
||||
}}
|
||||
>
|
||||
<Trans>Preview not available, click "Reload Preview".</Trans>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { forwardRef } from 'react';
|
||||
import { type ReactNode, forwardRef } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useServerApiState } from '../../states/ServerApiState';
|
||||
import InvenTreeIcon from './inventree.svg';
|
||||
|
||||
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} />;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,13 +36,9 @@ export function RenderBuildLine({
|
|||
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||
return (
|
||||
<RenderInlineModel
|
||||
primary={instance.part_detail.full_name}
|
||||
secondary={instance.quantity}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.status_custom_key,
|
||||
type: ModelType.build
|
||||
})}
|
||||
image={instance.part_detail.thumbnail || instance.part_detail.image}
|
||||
primary={instance.build_reference ?? instance.build}
|
||||
suffix={instance.pk}
|
||||
image={instance.part_detail?.thumbnail || instance.part_detail?.image}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,9 +114,10 @@ export type StockColumnProps = TableColumnProps & {
|
|||
// Render a StockItem instance within a table
|
||||
export function StockColumn(props: StockColumnProps): TableColumn {
|
||||
return {
|
||||
accessor: props.accessor ?? 'stock_item',
|
||||
title: t`Stock Item`,
|
||||
...props,
|
||||
ordering: props.ordering || 'stock',
|
||||
accessor: props.accessor || 'stock',
|
||||
render: (record: any) => {
|
||||
const stock_item =
|
||||
resolveItem(record, props.accessor ?? 'stock_item_detail') ?? {};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}, [
|
||||
columns,
|
||||
|
|
@ -315,6 +312,13 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||
tableState.selectedRecords
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const columnNames: string = dataColumns
|
||||
.map((col: any) => col.accessor)
|
||||
.join(',');
|
||||
setColumnHash(hashString(columnNames));
|
||||
}, [dataColumns]);
|
||||
|
||||
// Callback when column visibility is toggled
|
||||
const toggleColumn = useCallback(
|
||||
(columnName: string) => {
|
||||
|
|
@ -343,6 +347,13 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||
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
|
||||
useEffect(() => {
|
||||
tableState.setPage(1);
|
||||
|
|
@ -486,6 +497,10 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||
tableState.setPage(1);
|
||||
setSortStatus(status);
|
||||
|
||||
if (!status.columnAccessor) {
|
||||
console.error('Invalid column accessor provided for table sorting');
|
||||
}
|
||||
|
||||
setTableSorting(cacheKey)(status);
|
||||
},
|
||||
[cacheKey]
|
||||
|
|
|
|||
|
|
@ -533,6 +533,7 @@ export default function BuildOutputTable({
|
|||
title: t`Complete`,
|
||||
tooltip: t`Complete build output`,
|
||||
color: 'green',
|
||||
hidden: !production,
|
||||
icon: <InvenTreeIcon icon='success' />,
|
||||
onClick: () => {
|
||||
setSelectedOutputs([record]);
|
||||
|
|
@ -568,7 +569,7 @@ export default function BuildOutputTable({
|
|||
}
|
||||
];
|
||||
},
|
||||
[buildStatus, user, partId, hasTrackedItems]
|
||||
[buildStatus, build.status, user, partId, hasTrackedItems]
|
||||
);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -220,7 +220,6 @@ export default function PluginListTable() {
|
|||
// Uninstall an installed plugin
|
||||
// Must be inactive, not a builtin, not a sample, and installed as a package
|
||||
hidden:
|
||||
!user.isSuperuser() ||
|
||||
record.active ||
|
||||
record.is_builtin ||
|
||||
record.is_mandatory ||
|
||||
|
|
@ -244,8 +243,7 @@ export default function PluginListTable() {
|
|||
record.is_builtin ||
|
||||
record.is_mandatory ||
|
||||
record.is_sample ||
|
||||
record.is_installed ||
|
||||
!user.isSuperuser(),
|
||||
record.is_installed,
|
||||
title: t`Delete`,
|
||||
tooltip: t`Delete selected plugin configuration`,
|
||||
color: 'red',
|
||||
|
|
@ -355,7 +353,12 @@ export default function PluginListTable() {
|
|||
|
||||
// Custom table actions
|
||||
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 [];
|
||||
}
|
||||
|
||||
|
|
@ -376,7 +379,6 @@ export default function PluginListTable() {
|
|||
setPluginPackage('');
|
||||
installPluginModal.open();
|
||||
}}
|
||||
disabled={server.plugins_install_disabled || false}
|
||||
/>
|
||||
];
|
||||
}, [user, server]);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ export function ApiTokenTable({
|
|||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const generateToken = useCreateApiFormModal({
|
||||
url: ApiEndpoints.user_tokens,
|
||||
url: ApiEndpoints.user_token,
|
||||
method: 'GET',
|
||||
title: t`Generate Token`,
|
||||
fields: { name: {} },
|
||||
successMessage: t`Token generated`,
|
||||
|
|
@ -178,6 +179,7 @@ export function ApiTokenTable({
|
|||
onClose={close}
|
||||
title={<StylishText size='xl'>{t`Token`}</StylishText>}
|
||||
centered
|
||||
data-testid='generated-api-token'
|
||||
>
|
||||
<Text c='dimmed'>
|
||||
<Trans>
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -36,6 +36,25 @@ test('Stock - Basic Tests', async ({ browser }) => {
|
|||
await loadTab(page, 'Test Results');
|
||||
await page.getByText('395c6d5586e5fb656901d047be27e1f7').waitFor();
|
||||
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 }) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,30 @@
|
|||
import { expect, test } from './baseFixtures.js';
|
||||
import { adminuser } from './defaults.js';
|
||||
import { activateTableView, loadTab } from './helpers.js';
|
||||
import { doCachedLogin } from './login.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.
|
||||
* Select a number of stock items from the table,
|
||||
|
|
|
|||
|
|
@ -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.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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue