Merge branch 'inventree:master' into pr/ChristianSchindler/6305

This commit is contained in:
Matthias Mair 2024-04-16 13:12:36 +02:00 committed by GitHub
commit 2d89f068f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
195 changed files with 44698 additions and 38485 deletions

View File

@ -9,15 +9,13 @@ on:
pull_request_target:
types: [ "labeled", "closed" ]
permissions:
contents: read
jobs:
backport:
name: Backport PR
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
if: |
github.event.pull_request.merged == true
&& contains(github.event.pull_request.labels.*.name, 'backport')
@ -27,11 +25,10 @@ jobs:
)
steps:
- name: Backport Action
uses: sqren/backport-github-action@f7073a2287aefc1fa12685eb25a712ab5620445c # pin@v9.2.2
uses: sqren/backport-github-action@f54e19901f2a57f8b82360f2490d47ee82ec82c6 # pin@v9.2.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
auto_backport_label_prefix: backport-to-
add_original_reviewers: true
- name: Info log
if: ${{ success() }}

View File

@ -89,6 +89,7 @@ jobs:
docker run --rm inventree-test invoke --list
docker run --rm inventree-test gunicorn --version
docker run --rm inventree-test pg_dump --version
docker run --rm inventree-test test -f /home/inventree/src/backend/InvenTree/manage.py
- name: Build Docker Image
# Build the development docker image (using docker-compose.yml)
run: docker compose --project-directory . -f contrib/container/dev-docker-compose.yml build --no-cache
@ -127,7 +128,7 @@ jobs:
uses: docker/setup-buildx-action@2b51285047da1547ffb1b2203d8be4c0af6b1f20 # pin@v3.2.0
- name: Set up cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@e1523de7571e31dbe865fd2e80c5c7c23ae71eb4 # pin@v3.4.0
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # pin@v3.5.0
- name: Check if Dockerhub login is required
id: docker_login
run: |

View File

@ -24,6 +24,7 @@ env:
permissions:
contents: read
jobs:
paths-filter:
name: Filter
@ -34,6 +35,7 @@ jobs:
migrations: ${{ steps.filter.outputs.migrations }}
frontend: ${{ steps.filter.outputs.frontend }}
api: ${{ steps.filter.outputs.api }}
force: ${{ steps.force.outputs.force }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
@ -46,12 +48,21 @@ jobs:
- 'src/backend/requirements.txt'
- 'src/backend/requirements-dev.txt'
migrations:
- '**/test_migrations.py'
- '**/migrations/**'
- '.github/workflows**'
- 'src/backend/requirements.txt'
api:
- 'InvenTree/InvenTree/api_version.py'
- 'src/backend/InvenTree/InvenTree/api_version.py'
frontend:
- 'src/frontend/**'
- name: Is CI being forced?
run: echo "force=true" >> $GITHUB_OUTPUT
id: force
if: |
contains(github.event.pull_request.labels.*.name, 'dependency') ||
contains(github.event.pull_request.labels.*.name, 'full-run')
javascript:
name: Style - Classic UI [JS]
@ -73,13 +84,13 @@ jobs:
- name: Lint Javascript Files
run: |
python src/backend/InvenTree/manage.py prerender
npx eslint src/backend/InvenTree/InvenTree/static_i18n/i18n/*.js
cd src/backend && npx eslint InvenTree/InvenTree/static_i18n/i18n/*.js
pre-commit:
name: Style [pre-commit]
runs-on: ubuntu-20.04
needs: paths-filter
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true'
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true'
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
@ -125,7 +136,7 @@ jobs:
name: Tests - API Schema Documentation
runs-on: ubuntu-20.04
needs: paths-filter
if: needs.paths-filter.outputs.server == 'true'
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
env:
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3
@ -202,7 +213,7 @@ jobs:
echo "Version: $version"
mkdir export/${version}
mv schema.yml export/${version}/api.yaml
- uses: stefanzweifel/git-auto-commit-action@8756aa072ef5b4a080af5dc8fef36c5d586e521d # v5.0.0
- uses: stefanzweifel/git-auto-commit-action@8621497c8c39c72f3e2a999a26b4ca1b5058a842 # v5.0.1
with:
commit_message: "Update API schema for ${version}"
@ -210,7 +221,8 @@ jobs:
name: Tests - inventree-python
runs-on: ubuntu-20.04
needs: pre-commit
needs: [ 'pre-commit', 'paths-filter' ]
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
env:
wrapper_name: inventree-python
@ -251,7 +263,8 @@ jobs:
name: Tests - DB [SQLite] + Coverage
runs-on: ubuntu-20.04
needs: [ 'pre-commit' ]
needs: [ 'pre-commit', 'paths-filter' ]
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
continue-on-error: true # continue if a step fails so that coverage gets pushed
env:
@ -276,17 +289,29 @@ jobs:
run: python3 .github/scripts/check_migration_files.py
- name: Coverage Tests
run: invoke test --coverage
- name: Upload Coverage Report
- name: Upload Coverage Report to Coveralls
if: always()
uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949 # pin@v2.2.3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
flag-name: backend
file: coverage.xml
git-commit: ${{ github.sha }}
git-branch: ${{ github.ref }}
parallel: true
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.3.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: backend
postgres:
name: Tests - DB [PostgreSQL]
runs-on: ubuntu-20.04
needs: [ 'pre-commit' ]
needs: [ 'pre-commit', 'paths-filter' ]
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql
@ -330,7 +355,8 @@ jobs:
name: Tests - DB [MySQL]
runs-on: ubuntu-20.04
needs: [ 'pre-commit' ]
needs: [ 'pre-commit', 'paths-filter' ]
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
env:
# Database backend configuration
@ -373,7 +399,7 @@ jobs:
name: Tests - Migrations [PostgreSQL]
runs-on: ubuntu-latest
needs: paths-filter
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true'
if: ${{ (needs.paths-filter.outputs.force == 'true') || (github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true') }}
env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql
@ -404,13 +430,28 @@ jobs:
dev-install: true
update: true
- name: Run Tests
run: invoke test --migrations --report
run: invoke test --migrations --report --coverage
- name: Upload Coverage Report to Coveralls
if: always()
uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949 # pin@v2.2.3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
flag-name: migrations
git-commit: ${{ github.sha }}
git-branch: ${{ github.ref }}
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.3.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: migrations
migrations-checks:
name: Tests - Full Migration [SQLite]
runs-on: ubuntu-latest
needs: paths-filter
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true'
if: ${{ (needs.paths-filter.outputs.force == 'true') || (github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true') }}
env:
INVENTREE_DB_ENGINE: sqlite3
@ -467,7 +508,7 @@ jobs:
runs-on: ubuntu-20.04
timeout-minutes: 60
needs: [ 'pre-commit', 'paths-filter' ]
if: needs.paths-filter.outputs.frontend == 'true'
if: needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true'
env:
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
@ -485,36 +526,40 @@ jobs:
update: true
- name: Set up test data
run: invoke setup-test -i
- name: Rebuild thumbnails
run: invoke rebuild-thumbnails
- name: Install dependencies
run: inv frontend-compile
- name: Install Playwright Browsers
run: cd src/frontend && npx playwright install --with-deps
- name: Run Playwright tests
id: tests
run: cd src/frontend && npx nyc playwright test
- uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # pin@v4.3.1
name: Upload playwright report
if: always()
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with:
name: playwright-report
path: src/frontend/playwright-report/
retention-days: 30
retention-days: 14
- name: Report coverage
if: always()
run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false
- uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # pin@v4.3.1
name: Upload coverage report
if: always()
with:
name: coverage
path: src/frontend/coverage/
retention-days: 30
- name: Upload Coverage Report
- name: Upload Coverage Report to Coveralls
if: always()
uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949 # pin@v2.2.3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
flag-name: pui
git-commit: ${{ github.sha }}
git-branch: ${{ github.ref }}
parallel: true
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.3.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: pui
platform_ui_build:
name: Build - UI Platform
@ -530,11 +575,11 @@ jobs:
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
run: cd src/frontend && npm run compile && npm run build
run: cd src/frontend && yarn run compile && yarn run build
- name: Zip frontend
run: |
cd src/backend/InvenTree/web/static
zip -r frontend-build.zip web/
zip -r frontend-build.zip web/ web/.vite
- uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # pin@v4.3.1
with:
name: frontend-build
@ -543,13 +588,15 @@ jobs:
finish_coverage:
name: Finish Coverage
runs-on: ubuntu-20.04
needs: ["platform_ui", "coverage", "paths-filter"]
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true'
needs: ["platform_ui", "coverage", "migration-tests", "paths-filter"]
if: (needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true') && (needs.platform_ui.result == 'success' || needs.coverage.result == 'success' || needs.migration-tests.result == 'success')
steps:
- name: Finish coverage reporting
uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949 # pin@v2.2.3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
carryforward: "pui,backend"
carryforward: "pui,backend,migrations"
parallel-finished: true
git-commit: ${{ github.sha }}
git-branch: ${{ github.ref }}

View File

@ -67,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
uses: github/codeql-action/upload-sarif@df5a14dc28094dc936e103b37d749c6628682b60 # v3.25.0
with:
sarif_file: results.sarif

1
.gitignore vendored
View File

@ -85,6 +85,7 @@ env/
# Locale stats file
src/backend/InvenTree/InvenTree/locale_stats.json
src/backend/InvenTree/InvenTree/licenses.txt
# node.js
node_modules/

View File

@ -17,7 +17,7 @@ repos:
- id: check-yaml
- id: mixed-line-ending
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.4
rev: v0.3.5
hooks:
- id: ruff-format
args: [--preview]
@ -27,7 +27,7 @@ repos:
--preview
]
- repo: https://github.com/astral-sh/uv-pre-commit
rev: v0.1.24
rev: 0.1.29
hooks:
- id: pip-compile
name: pip-compile requirements-dev.in
@ -61,7 +61,7 @@ repos:
- "prettier@^2.4.1"
- "@trivago/prettier-plugin-sort-imports"
- repo: https://github.com/pre-commit/mirrors-eslint
rev: "v9.0.0-rc.0"
rev: "v9.0.0"
hooks:
- id: eslint
additional_dependencies:

View File

@ -1,7 +1,7 @@
# Web process: gunicorn
web: env/bin/gunicorn --chdir $APP_HOME/src/backend/InvenTree -c src/backend/InvenTree/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:$PORT
# Worker process: qcluster
worker: env/bin/python src/backendInvenTree/manage.py qcluster
worker: env/bin/python src/backend/InvenTree/manage.py qcluster
# Invoke commands
invoke: echo "" | echo "" && . env/bin/activate && invoke
# CLI: Provided for backwards compatibility

View File

@ -14,6 +14,7 @@
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=inventree_InvenTree&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=inventree_InvenTree)
[![Coveralls](https://img.shields.io/coveralls/github/inventree/InvenTree)](https://coveralls.io/github/inventree/InvenTree)
[![codecov](https://codecov.io/gh/inventree/InvenTree/graph/badge.svg?token=9DZRGUUV7B)](https://codecov.io/gh/inventree/InvenTree)
[![Crowdin](https://badges.crowdin.net/inventree/localized.svg)](https://crowdin.com/project/inventree)
![GitHub commit activity](https://img.shields.io/github/commit-activity/m/inventree/inventree)
[![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/r/inventree/inventree)

View File

@ -1,8 +0,0 @@
{
"repoOwner": "Oliver Walters",
"repoName": "InvenTree",
"targetBranchChoices": [],
"branchLabelMapping": {
"^backport-to-(.+)$": "$1"
}
}

28
codecov.yml Normal file
View File

@ -0,0 +1,28 @@
coverage:
status:
project:
default:
target: 82%
github_checks:
annotations: true
flag_management:
default_rules:
carryforward: true
individual_flags:
- name: backend
carryforward: true
statuses:
- type: project
target: 85%
- name: migrations
carryforward: true
statuses:
- type: project
target: 40%
- name: pui
carryforward: true
statuses:
- type: project
target: 45%

View File

@ -33,6 +33,8 @@ ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
ENV INVENTREE_BACKUP_DIR="${INVENTREE_DATA_DIR}/backup"
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
ENV INVENTREE_BACKEND_DIR="${INVENTREE_HOME}/src/backend"
# InvenTree configuration files
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
@ -127,19 +129,16 @@ ENV INVENTREE_COMMIT_DATE="${commit_date}"
ENV PATH=/root/.local/bin:$PATH
COPY --from=prebuild /root/.local /root/.local
ENV INVENTREE_BACKEND_DIR="${INVENTREE_HOME}"
# Copy source code
COPY src/backend/InvenTree ./InvenTree
COPY --from=frontend ${INVENTREE_HOME}/src/backend/InvenTree/web/static/web ./src/backend/InvenTree/web/static/web
COPY src/backend/InvenTree ${INVENTREE_BACKEND_DIR}/InvenTree
COPY --from=frontend ${INVENTREE_BACKEND_DIR}/InvenTree/web/static/web ${INVENTREE_BACKEND_DIR}/InvenTree/web/static/web
# Launch the production server
CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ${INVENTREE_BACKEND_DIR}/InvenTree
FROM inventree_base AS dev
ENV INVENTREE_BACKEND_DIR="${INVENTREE_HOME}/src/backend"
# Vite server (for local frontend development)
EXPOSE 5173

View File

@ -27,13 +27,13 @@ The following guide provides a streamlined production InvenTree installation, wi
### Required Files
The following files required for this setup are provided with the InvenTree source, located in the `./docker/` directory of the [InvenTree source code](https://github.com/inventree/InvenTree/tree/master/docker/):
The following files required for this setup are provided with the InvenTree source, located in the `/contrib/container/` directory of the [InvenTree source code](https://github.com/inventree/InvenTree/tree/master/contrib/container/):
| Filename | Description |
| --- | --- |
| [docker-compose.yml](https://github.com/inventree/InvenTree/blob/master/docker/docker-compose.yml) | The docker compose script |
| [.env](https://github.com/inventree/InvenTree/blob/master/docker/.env) | Environment variables |
| [Caddyfile](https://github.com/inventree/InvenTree/blob/master/docker/Caddyfile) | Caddy configuration file |
| [docker-compose.yml](https://github.com/inventree/InvenTree/blob/master/contrib/container/docker-compose.yml) | The docker compose script |
| [.env](https://github.com/inventree/InvenTree/blob/master/contrib/container/.env) | Environment variables |
| [Caddyfile](https://github.com/inventree/InvenTree/blob/master/contrib/container/Caddyfile) | Caddy configuration file |
Download these files to a directory on your local machine.

View File

@ -1,3 +1,3 @@
# Dummy requirements file to trigger the package pipeline
# The new requirements file is located in src/backend/requirements.txt
# The backend requirements file is located in src/backend/requirements.txt
#

View File

@ -84,25 +84,48 @@ class InvenTreeResource(ModelResource):
return [f for f in fields if f.column_name not in fields_to_exclude]
def before_import(self, dataset, using_transactions, dry_run, **kwargs):
"""Run custom code before importing data.
- Determine the list of fields which need to be converted to empty strings
"""
# Construct a map of field names
db_fields = {field.name: field for field in self.Meta.model._meta.fields}
for field_name, field in self.fields.items():
# Skip read-only fields (they cannot be imported)
if field.readonly:
continue
# Determine the name of the associated column in the dataset
column = getattr(field, 'column_name', field_name)
# Determine the attribute name of the associated database field
attribute = getattr(field, 'attribute', field_name)
# Check if the associated database field is a non-nullable string
if db_field := db_fields.get(attribute):
if (
isinstance(db_field, CharField)
and db_field.blank
and not db_field.null
):
if column not in self.CONVERT_NULL_FIELDS:
self.CONVERT_NULL_FIELDS.append(column)
return super().before_import(dataset, using_transactions, dry_run, **kwargs)
def before_import_row(self, row, row_number=None, **kwargs):
"""Run custom code before importing each row.
- Convert any null fields to empty strings, for fields which do not support null values
"""
# We can automatically determine which fields might need such a conversion
for field in self.Meta.model._meta.fields:
if (
isinstance(field, CharField)
and field.blank
and not field.null
and field.name not in self.CONVERT_NULL_FIELDS
):
self.CONVERT_NULL_FIELDS.append(field.name)
for field in self.CONVERT_NULL_FIELDS:
if field in row and row[field] is None:
row[field] = ''
return super().before_import_row(row, row_number, **kwargs)
class CustomRateAdmin(RateAdmin):
"""Admin interface for the Rate class."""

View File

@ -1,6 +1,9 @@
"""Main JSON interface views."""
import json
import logging
import sys
from pathlib import Path
from django.conf import settings
from django.db import transaction
@ -31,6 +34,60 @@ from .status import check_system_health, is_worker_running
from .version import inventreeApiText
from .views import AjaxView
logger = logging.getLogger('inventree')
class LicenseViewSerializer(serializers.Serializer):
"""Serializer for license information."""
backend = serializers.CharField(help_text='Backend licenses texts', read_only=True)
frontend = serializers.CharField(
help_text='Frontend licenses texts', read_only=True
)
class LicenseView(APIView):
"""Simple JSON endpoint for InvenTree license information."""
permission_classes = [permissions.IsAuthenticated]
def read_license_file(self, path: Path) -> list:
"""Extract license information from the provided file.
Arguments:
path: Path to the license file
Returns: A list of items containing the license information
"""
# Check if the file exists
if not path.exists():
logger.error("License file not found at '%s'", path)
return []
try:
data = json.loads(path.read_text())
except json.JSONDecodeError as e:
logger.exception("Failed to parse license file '%s': %s", path, e)
return []
except Exception as e:
logger.exception("Exception while reading license file '%s': %s", path, e)
return []
# Ensure consistent string between backend and frontend licenses
return [{key.lower(): value for key, value in entry.items()} for entry in data]
@extend_schema(responses={200: OpenApiResponse(response=LicenseViewSerializer)})
def get(self, request, *args, **kwargs):
"""Return information about the InvenTree server."""
backend = Path(__file__).parent.joinpath('licenses.txt')
frontend = Path(__file__).parent.parent.joinpath(
'web/static/web/.vite/dependencies.json'
)
return JsonResponse({
'backend': self.read_license_file(backend),
'frontend': self.read_license_file(frontend),
})
class VersionViewSerializer(serializers.Serializer):
"""Serializer for a single version."""

View File

@ -1,11 +1,20 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 185
INVENTREE_API_VERSION = 187
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v187 - 2024-03-10 : https://github.com/inventree/InvenTree/pull/6985
- Allow Part list endpoint to be sorted by pricing_min and pricing_max values
- Allow BomItem list endpoint to be sorted by pricing_min and pricing_max values
- Allow InternalPrice and SalePrice endpoints to be sorted by quantity
- Adds total pricing values to BomItem serializer
v186 - 2024-03-26 : https://github.com/inventree/InvenTree/pull/6855
- Adds license information to the API
v185 - 2024-03-24 : https://github.com/inventree/InvenTree/pull/6836
- Remove /plugin/activate endpoint
- Update docstrings and typing for various API endpoints (no functional changes)

View File

@ -268,6 +268,49 @@ MIDDLEWARE = CONFIG.get(
],
)
# In DEBUG mode, add support for django-querycount
# Ref: https://github.com/bradmontgomery/django-querycount
if DEBUG and get_boolean_setting(
'INVENTREE_DEBUG_QUERYCOUNT', 'debug_querycount', False
):
MIDDLEWARE.append('querycount.middleware.QueryCountMiddleware')
QUERYCOUNT = {
'THRESHOLDS': {
'MEDIUM': 50,
'HIGH': 200,
'MIN_TIME_TO_LOG': 0,
'MIN_QUERY_COUNT_TO_LOG': 0,
},
'IGNORE_REQUEST_PATTERNS': ['^(?!\/(api)?(plugin)?\/).*'],
'IGNORE_SQL_PATTERNS': [],
'DISPLAY_DUPLICATES': 3,
'RESPONSE_HEADER': 'X-Django-Query-Count',
}
ADMIN_SHELL_ENABLE = False
ADMIN_SHELL_IMPORT_DJANGO = False
ADMIN_SHELL_IMPORT_MODELS = False
# In DEBUG mode, add support for django-admin-shell
# Ref: https://github.com/djk2/django-admin-shell
if (
DEBUG
and INVENTREE_ADMIN_ENABLED
and get_boolean_setting('INVENTREE_DEBUG_SHELL', 'debug_shell', False)
): # noqa
try:
import django_admin_shell
INSTALLED_APPS.append('django_admin_shell')
ADMIN_SHELL_ENABLE = True
logger.warning('Admin shell is enabled')
except ModuleNotFoundError:
logger.warning(
'django-admin-shell is not installed - Admin shell is not enabled'
)
AUTHENTICATION_BACKENDS = CONFIG.get(
'authentication_backends',
[

View File

@ -1148,12 +1148,8 @@ class TestSettings(InvenTreeTestCase):
superuser = True
def in_env_context(self, envs=None):
def in_env_context(self, envs):
"""Patch the env to include the given dict."""
# Set default - see B006
if envs is None:
envs = {}
return mock.patch.dict(os.environ, envs)
def run_reload(self, envs=None):
@ -1588,15 +1584,15 @@ class ClassValidationMixinTest(TestCase):
def test(self):
"""Test function."""
pass
...
def test1(self):
"""Test function."""
pass
...
def test2(self):
"""Test function."""
pass
...
required_attributes = ['NAME']
required_overrides = [test, [test1, test2]]
@ -1616,11 +1612,11 @@ class ClassValidationMixinTest(TestCase):
def test(self):
"""Test function."""
pass
...
def test2(self):
"""Test function."""
pass
...
TestClass.validate()
@ -1643,7 +1639,7 @@ class ClassValidationMixinTest(TestCase):
def test2(self):
"""Test function."""
pass
...
with self.assertRaisesRegex(
NotImplementedError,

View File

@ -157,12 +157,14 @@ class UserMixin:
if type(assign_all) is not bool:
# Raise exception if common mistake is made!
raise TypeError('assignRole: assign_all must be a boolean value')
raise TypeError(
'assignRole: assign_all must be a boolean value'
) # pragma: no cover
if not role and not assign_all:
raise ValueError(
'assignRole: either role must be provided, or assign_all must be set'
)
) # pragma: no cover
if not assign_all and role:
rule, perm = role.split('.')
@ -241,14 +243,18 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
yield # your test will be run here
if verbose:
msg = '\r\n%s' % json.dumps(context.captured_queries, indent=4)
msg = '\r\n%s' % json.dumps(
context.captured_queries, indent=4
) # pragma: no cover
else:
msg = None
n = len(context.captured_queries)
if debug:
print(f'Expected less than {value} queries, got {n} queries')
print(
f'Expected less than {value} queries, got {n} queries'
) # pragma: no cover
self.assertLess(n, value, msg=msg)
@ -258,7 +264,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
if expected_code is None:
return
if expected_code != response.status_code:
if expected_code != response.status_code: # pragma: no cover
print(
f"Unexpected {method} response at '{url}': status_code = {response.status_code}"
)
@ -280,11 +286,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
response = self.client.options(url)
self.assertEqual(response.status_code, 200)
actions = response.data.get('actions', None)
if not actions:
actions = {}
actions = response.data.get('actions', {})
return actions
def get(self, url, data=None, expected_code=200, format='json', **kwargs):

View File

@ -39,7 +39,14 @@ from stock.urls import stock_urls
from web.urls import api_urls as web_api_urls
from web.urls import urlpatterns as platform_urls
from .api import APISearchView, InfoView, NotFoundView, VersionTextView, VersionView
from .api import (
APISearchView,
InfoView,
LicenseView,
NotFoundView,
VersionTextView,
VersionView,
)
from .magic_login import GetSimpleLoginView
from .social_auth_urls import (
EmailListView,
@ -99,6 +106,7 @@ apipatterns = [
name='schema',
),
# InvenTree information endpoints
path('license/', LicenseView.as_view(), name='api-license'), # license info
path(
'version-text', VersionTextView.as_view(), name='api-version-text'
), # version text
@ -377,6 +385,7 @@ if settings.ENABLE_CLASSIC_FRONTEND:
classic_frontendpatterns = [
# Apps
#
path('build/', include(build_urls)),
path('common/', include(common_urls)),
path('company/', include(company_urls)),
@ -426,7 +435,11 @@ classic_frontendpatterns = [
urlpatterns = []
if settings.INVENTREE_ADMIN_ENABLED:
admin_url = (settings.INVENTREE_ADMIN_URL,)
admin_url = settings.INVENTREE_ADMIN_URL
if settings.ADMIN_SHELL_ENABLE: # noqa
urlpatterns += [path(f'{admin_url}/shell/', include('django_admin_shell.urls'))]
urlpatterns += [
path(f'{admin_url}/error_log/', include('error_report.urls')),
path(f'{admin_url}/', admin.site.urls, name='inventree-admin'),

View File

@ -258,6 +258,13 @@ class UserSettingsList(SettingsList):
common.models.InvenTreeUserSetting.build_default_values(user=request.user)
return super().list(request, *args, **kwargs)
def get_queryset(self):
"""Return prefetched queryset."""
queryset = super().get_queryset()
queryset = queryset.prefetch_related('user')
return queryset
def filter_queryset(self, queryset):
"""Only list settings which apply to the current user."""
try:

View File

@ -46,7 +46,7 @@ class NewsFeedTests(TestCase):
"""Tests that news feed is updated when accessing a valid URL."""
try:
common_tasks.update_news_feed()
except Exception as ex:
except Exception as ex: # pragma: no cover
self.fail(f'News feed raised exceptions: {ex}')
self.assertNotEqual(NewsFeedEntry.objects.all().count(), 0)

View File

@ -1048,18 +1048,18 @@ class ColorThemeTest(TestCase):
"""Test that default choices are returned."""
result = ColorTheme.get_color_themes_choices()
# skip
# skip due to directories not being set up
if not result:
return
return # pragma: no cover
self.assertIn(('default', 'Default'), result)
def test_valid_choice(self):
"""Check that is_valid_choice works correctly."""
result = ColorTheme.get_color_themes_choices()
# skip
# skip due to directories not being set up
if not result:
return
return # pragma: no cover
# check wrong reference
self.assertFalse(ColorTheme.is_valid_choice('abcdd'))
@ -1099,10 +1099,12 @@ class CurrencyAPITests(InvenTreeAPITestCase):
# Exit early
return
# Delay and try again
time.sleep(10)
# Delay and try again - might have problems with exchange rate endpoint
time.sleep(10) # pragma: no cover
raise TimeoutError('Could not refresh currency exchange data after 5 attempts')
raise TimeoutError(
'Could not refresh currency exchange data after 5 attempts'
) # pragma: no cover
class NotesImageTest(InvenTreeAPITestCase):

View File

@ -468,9 +468,13 @@ class SupplierPriceBreakList(ListCreateAPI):
return self.serializer_class(*args, **kwargs)
filter_backends = ORDER_FILTER
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['quantity']
ordering_fields = ['quantity', 'supplier', 'SKU', 'price']
search_fields = ['part__SKU', 'part__supplier__name']
ordering_field_aliases = {'supplier': 'part__supplier__name', 'SKU': 'part__SKU'}
ordering = 'quantity'

View File

@ -5,9 +5,6 @@ from InvenTree.unit_test import InvenTreeTestCase
from .transition import StateTransitionMixin, TransitionMethod, storage
# Global variables to determine which transition classes raises an exception
global raise_storage
global raise_function
raise_storage = False
raise_function = False
@ -90,7 +87,7 @@ class TransitionTests(InvenTreeTestCase):
if raise_function:
return 1234
else:
return False
return False # pragma: no cover # Return false to keep other transitions working
storage.collect()
self.assertIn(ValidImplementationNoEffect, storage.list)

View File

@ -23,7 +23,7 @@ class GeneralStatus(StatusCode):
def GHI(self): # This should be ignored
"""A invalid function."""
pass
...
class GeneralStateTest(InvenTreeTestCase):

View File

@ -64,11 +64,6 @@ class LabelTest(InvenTreeAPITestCase):
response = self.get(url, {'enabled': False})
self.assertEqual(len(response.data), 0)
# Disable each report
for label in labels:
label.enabled = False
label.save()
# Filter by "enabled" status
response = self.get(url, {'enabled': True})
self.assertEqual(len(response.data), 0)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -143,7 +143,7 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
for error in errors_msgs:
if re.match(pattern, error):
break
else:
else: # pragma: no cover
errors_str = '\n'.join([f'- {e}' for e in errors_msgs])
self.fail(
f"""Error message matching pattern '{pattern}' not found in machine registry errors:\n{errors_str}"""

View File

@ -272,15 +272,21 @@ class TestLabelPrinterMachineType(TestMachineRegistryMixin, InvenTreeAPITestCase
self.print_labels.assert_called_once()
self.assertEqual(self.print_labels.call_args.args[0], self.machine.machine)
self.assertEqual(self.print_labels.call_args.args[1], label)
self.assertQuerySetEqual(
self.print_labels.call_args.args[2], parts, transform=lambda x: x
)
# TODO re-activate test
# self.assertQuerySetEqual(
# self.print_labels.call_args.args[2], parts, transform=lambda x: x
# )
self.assertIn('printing_options', self.print_labels.call_args.kwargs)
self.assertEqual(
self.print_labels.call_args.kwargs['printing_options'],
{'copies': 1, 'test_option': 2},
)
return
# TODO re-activate test
# test the single print label method calls
self.assertEqual(self.print_label.call_count, 2)
self.assertEqual(self.print_label.call_args.args[0], self.machine.machine)

View File

@ -154,11 +154,11 @@ class LineItemFilter(rest_filters.FilterSet):
# Filter by order status
order_status = rest_filters.NumberFilter(
label='order_status', field_name='order__status'
label=_('Order Status'), field_name='order__status'
)
has_pricing = rest_filters.BooleanFilter(
label='Has Pricing', method='filter_has_pricing'
label=_('Has Pricing'), method='filter_has_pricing'
)
def filter_has_pricing(self, queryset, name, value):
@ -425,9 +425,38 @@ class PurchaseOrderLineItemFilter(LineItemFilter):
price_field = 'purchase_price'
model = models.PurchaseOrderLineItem
fields = ['order', 'part']
fields = []
pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
order = rest_filters.ModelChoiceFilter(
queryset=models.PurchaseOrder.objects.all(),
field_name='order',
label=_('Order'),
)
order_complete = rest_filters.BooleanFilter(
label=_('Order Complete'), method='filter_order_complete'
)
def filter_order_complete(self, queryset, name, value):
"""Filter by whether the order is 'complete' or not."""
if str2bool(value):
return queryset.filter(order__status=PurchaseOrderStatus.COMPLETE.value)
return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value)
part = rest_filters.ModelChoiceFilter(
queryset=SupplierPart.objects.all(), field_name='part', label=_('Supplier Part')
)
base_part = rest_filters.ModelChoiceFilter(
queryset=Part.objects.filter(purchaseable=True),
field_name='part__part',
label=_('Internal Part'),
)
pending = rest_filters.BooleanFilter(
method='filter_pending', label=_('Order Pending')
)
def filter_pending(self, queryset, name, value):
"""Filter by "pending" status (order status = pending)."""
@ -435,7 +464,9 @@ class PurchaseOrderLineItemFilter(LineItemFilter):
return queryset.filter(order__status__in=PurchaseOrderStatusGroups.OPEN)
return queryset.exclude(order__status__in=PurchaseOrderStatusGroups.OPEN)
received = rest_filters.BooleanFilter(label='received', method='filter_received')
received = rest_filters.BooleanFilter(
label=_('Items Received'), method='filter_received'
)
def filter_received(self, queryset, name, value):
"""Filter by lines which are "received" (or "not" received).
@ -542,25 +573,6 @@ class PurchaseOrderLineItemList(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
def filter_queryset(self, queryset):
"""Additional filtering options."""
params = self.request.query_params
queryset = super().filter_queryset(queryset)
base_part = params.get('base_part', None)
if base_part:
try:
base_part = Part.objects.get(pk=base_part)
queryset = queryset.filter(part__part=base_part)
except (ValueError, Part.DoesNotExist):
pass
return queryset
def download_queryset(self, queryset, export_format):
"""Download the requested queryset as a file."""
dataset = PurchaseOrderLineItemResource().export(queryset=queryset)
@ -577,6 +589,8 @@ class PurchaseOrderLineItemList(
'MPN': 'part__manufacturer_part__MPN',
'SKU': 'part__SKU',
'part_name': 'part__part__name',
'order': 'order__reference',
'complete_date': 'order__complete_date',
}
ordering_fields = [
@ -589,6 +603,8 @@ class PurchaseOrderLineItemList(
'SKU',
'total_price',
'target_date',
'order',
'complete_date',
]
search_fields = [
@ -791,7 +807,15 @@ class SalesOrderLineItemFilter(LineItemFilter):
price_field = 'sale_price'
model = models.SalesOrderLineItem
fields = ['order', 'part']
fields = []
order = rest_filters.ModelChoiceFilter(
queryset=models.SalesOrder.objects.all(), field_name='order', label=_('Order')
)
part = rest_filters.ModelChoiceFilter(
queryset=Part.objects.all(), field_name='part', label=_('Part')
)
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
@ -806,6 +830,17 @@ class SalesOrderLineItemFilter(LineItemFilter):
return queryset.filter(q)
return queryset.exclude(q)
order_complete = rest_filters.BooleanFilter(
label=_('Order Complete'), method='filter_order_complete'
)
def filter_order_complete(self, queryset, name, value):
"""Filter by whether the order is 'complete' or not."""
if str2bool(value):
return queryset.filter(order__status__in=SalesOrderStatusGroups.COMPLETE)
return queryset.exclude(order__status__in=SalesOrderStatusGroups.COMPLETE)
class SalesOrderLineItemMixin:
"""Mixin class for SalesOrderLineItem endpoints."""
@ -862,9 +897,24 @@ class SalesOrderLineItemList(SalesOrderLineItemMixin, APIDownloadMixin, ListCrea
return DownloadFile(filedata, filename)
filter_backends = SEARCH_ORDER_FILTER
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['part__name', 'quantity', 'reference', 'target_date']
ordering_fields = [
'customer',
'order',
'part',
'part__name',
'quantity',
'reference',
'sale_price',
'target_date',
]
ordering_field_aliases = {
'customer': 'order__customer__name',
'part': 'part__name',
'order': 'order__reference',
}
search_fields = ['part__name', 'quantity', 'reference']

View File

@ -38,7 +38,6 @@ from InvenTree.helpers import (
is_ajax,
isNull,
str2bool,
str2int,
)
from InvenTree.mixins import (
CreateAPI,
@ -386,9 +385,10 @@ class PartSalePriceList(ListCreateAPI):
queryset = PartSellPriceBreak.objects.all()
serializer_class = part_serializers.PartSalePriceSerializer
filter_backends = [DjangoFilterBackend]
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = ['part']
ordering_fields = ['quantity', 'price']
ordering = 'quantity'
class PartInternalPriceDetail(RetrieveUpdateDestroyAPI):
@ -405,9 +405,10 @@ class PartInternalPriceList(ListCreateAPI):
serializer_class = part_serializers.PartInternalPriceSerializer
permission_required = 'roles.sales_order.show'
filter_backends = [DjangoFilterBackend]
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = ['part']
ordering_fields = ['quantity', 'price']
ordering = 'quantity'
class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
@ -1407,8 +1408,17 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
'category',
'last_stocktake',
'units',
'pricing_min',
'pricing_max',
'pricing_updated',
]
ordering_field_aliases = {
'pricing_min': 'pricing_data__overall_min',
'pricing_max': 'pricing_data__overall_max',
'pricing_updated': 'pricing_data__updated',
}
# Default ordering
ordering = 'name'
@ -1939,9 +1949,19 @@ class BomList(BomMixin, ListCreateDestroyAPIView):
'inherited',
'optional',
'consumable',
'pricing_min',
'pricing_max',
'pricing_min_total',
'pricing_max_total',
'pricing_updated',
]
ordering_field_aliases = {'sub_part': 'sub_part__name'}
ordering_field_aliases = {
'sub_part': 'sub_part__name',
'pricing_min': 'sub_part__pricing_data__overall_min',
'pricing_max': 'sub_part__pricing_data__overall_max',
'pricing_updated': 'sub_part__pricing_data__updated',
}
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI):

View File

@ -616,6 +616,7 @@ class PartSerializer(
'virtual',
'pricing_min',
'pricing_max',
'pricing_updated',
'responsible',
# Annotated fields
'allocated_to_build_orders',
@ -678,6 +679,7 @@ class PartSerializer(
if not pricing:
self.fields.pop('pricing_min')
self.fields.pop('pricing_max')
self.fields.pop('pricing_updated')
def get_api_url(self):
"""Return the API url associated with this serializer."""
@ -843,6 +845,9 @@ class PartSerializer(
pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(
source='pricing_data.overall_max', allow_null=True, read_only=True
)
pricing_updated = serializers.DateTimeField(
source='pricing_data.updated', allow_null=True, read_only=True
)
parameters = PartParameterSerializer(many=True, read_only=True)
@ -1413,6 +1418,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'part_detail',
'pricing_min',
'pricing_max',
'pricing_min_total',
'pricing_max_total',
'pricing_updated',
'quantity',
'reference',
'sub_part',
@ -1451,6 +1459,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
if not pricing:
self.fields.pop('pricing_min')
self.fields.pop('pricing_max')
self.fields.pop('pricing_min_total')
self.fields.pop('pricing_max_total')
self.fields.pop('pricing_updated')
quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
@ -1481,10 +1492,22 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
# Cached pricing fields
pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(
source='sub_part.pricing.overall_min', allow_null=True, read_only=True
source='sub_part.pricing_data.overall_min', allow_null=True, read_only=True
)
pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(
source='sub_part.pricing.overall_max', allow_null=True, read_only=True
source='sub_part.pricing_data.overall_max', allow_null=True, read_only=True
)
pricing_min_total = InvenTree.serializers.InvenTreeMoneySerializer(
allow_null=True, read_only=True
)
pricing_max_total = InvenTree.serializers.InvenTreeMoneySerializer(
allow_null=True, read_only=True
)
pricing_updated = serializers.DateTimeField(
source='sub_part.pricing_data.updated', allow_null=True, read_only=True
)
# Annotated fields for available stock
@ -1504,6 +1527,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category')
queryset = queryset.prefetch_related('sub_part__pricing_data')
queryset = queryset.prefetch_related(
'sub_part__stock_items',
@ -1531,6 +1555,18 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
available_stock = total_stock - build_order_allocations - sales_order_allocations
"""
# Annotate with the 'total pricing' information based on unit pricing and quantity
queryset = queryset.annotate(
pricing_min_total=ExpressionWrapper(
F('quantity') * F('sub_part__pricing_data__overall_min'),
output_field=models.DecimalField(),
),
pricing_max_total=ExpressionWrapper(
F('quantity') * F('sub_part__pricing_data__overall_max'),
output_field=models.DecimalField(),
),
)
ref = 'sub_part__'
# Annotate with the total "on order" amount for the sub-part

View File

@ -62,16 +62,19 @@ class SettingsMixin:
"""Does this plugin use custom global settings."""
return bool(self.settings)
def get_setting(self, key, cache=False):
def get_setting(self, key, cache=False, backup_value=None):
"""Return the 'value' of the setting associated with this plugin.
Arguments:
key: The 'name' of the setting value to be retrieved
cache: Whether to use RAM cached value (default = False)
backup_value: A backup value to return if the setting is not found
"""
from plugin.models import PluginSetting
return PluginSetting.get_setting(key, plugin=self.plugin_config(), cache=cache)
return PluginSetting.get_setting(
key, plugin=self.plugin_config(), cache=cache, backup_value=backup_value
)
def set_setting(self, key, value, user=None):
"""Set plugin setting value by key."""

View File

@ -11,6 +11,15 @@ from stock.models import StockItem, StockLocation
class LocatePluginTests(InvenTreeAPITestCase):
"""Tests for LocateMixin."""
def setUp(self):
"""Set up the test case."""
super().setUp()
# Activate plugin
config = registry.get_plugin('samplelocate').plugin_config()
config.active = True
config.save()
fixtures = ['category', 'part', 'location', 'stock']
def test_installed(self):

View File

@ -554,9 +554,6 @@ class StockItemListTest(StockAPITestCase):
)
self.assertTrue(len(response.data) < StockItem.objects.count())
for result in response.data:
self.assertIsNone(result['location'])
# Filter with "cascade=True"
response = self.get(
self.list_url, {'location': 'null', 'cascade': True}, expected_code=200

View File

@ -64,6 +64,7 @@ class TestSerialNumberMigration(MigratorTestCase):
big_ref_item.serial,
'9999999999999999999999999999999999999999999999999999999999999',
)
self.assertEqual(big_ref_item.serial_int, 0x7FFFFFFF)

View File

@ -218,7 +218,7 @@ function renderStockItem(data, parameters={}) {
}
if (data.quantity == 0) {
stock_detail = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock"% }</span>`;
stock_detail = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
} else {
if (data.serial && data.quantity == 1) {
stock_detail = `{% trans "Serial Number" %}: ${data.serial}`;

View File

@ -488,7 +488,7 @@ function receiveReturnOrderItems(order_id, line_items, options={}) {
if (line_items.length == 0) {
showAlertDialog(
'{% trans "Select Line Items"% }',
'{% trans "Select Line Items" %}',
'{% trans "At least one line item must be selected" %}'
);
return;

View File

@ -26,6 +26,11 @@ class TemplateTagTest(InvenTreeTestCase):
def test_spa_bundle(self):
"""Test the 'spa_bundle' template tag."""
resp = spa_helper.spa_bundle()
if not resp:
# No Vite, no test
# TODO: Add a test for the non-Vite case (docker)
return # pragma: no cover
shipped_js = resp.split('<script type="module" src="')[1:]
self.assertTrue(len(shipped_js) > 0)
self.assertTrue(len(shipped_js) == 3)

View File

@ -1,5 +1,5 @@
{
"name": "InvenTree",
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -95,9 +95,9 @@
}
},
"node_modules/@humanwhocodes/object-schema": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz",
"integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw=="
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
@ -472,9 +472,9 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
"node_modules/fastq": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
"integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
"integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
"dependencies": {
"reusify": "^1.0.4"
}
@ -506,11 +506,12 @@
}
},
"node_modules/flat-cache": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
"integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
"dependencies": {
"flatted": "^3.1.0",
"flatted": "^3.2.9",
"keyv": "^4.5.3",
"rimraf": "^3.0.2"
},
"engines": {
@ -518,9 +519,9 @@
}
},
"node_modules/flatted": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ=="
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw=="
},
"node_modules/fs.realpath": {
"version": "1.0.0",
@ -672,6 +673,11 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@ -682,6 +688,14 @@
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",

View File

@ -2,6 +2,8 @@
-c requirements.txt
coverage[toml] # Unit test coverage
coveralls==2.1.2 # Coveralls linking (for tracking coverage) # PINNED 2022-06-28 - Old version needed for correct upload
django-admin-shell # Remote shell access
django-querycount # Display number of URL queries for requests
django-slowtests # Show which unit tests are running slowly
django-test-migrations # Unit testing for database migrations
isort # python import sorting

View File

@ -1,8 +1,8 @@
# This file was autogenerated by uv via the following command:
# uv pip compile src/backend/requirements-dev.in -o src/backend/requirements-dev.txt --python-version=3.9 --no-strip-extras
asgiref==3.8.0
asgiref==3.8.1
# via django
build==1.1.1
build==1.2.1
# via pip-tools
certifi==2024.2.2
# via requests
@ -24,18 +24,22 @@ cryptography==42.0.5
distlib==0.3.8
# via virtualenv
django==4.2.11
# via django-slowtests
# via
# django-admin-shell
# django-slowtests
django-admin-shell==2.0.1
django-querycount==0.8.3
django-slowtests==1.1.1
django-test-migrations==1.3.0
docopt==0.6.2
# via coveralls
filelock==3.13.1
filelock==3.13.3
# via virtualenv
identify==2.5.35
# via pre-commit
idna==3.6
# via requests
importlib-metadata==6.11.0
importlib-metadata==7.0.0
# via build
isort==5.13.2
nodeenv==1.8.0
@ -49,7 +53,7 @@ pip-tools==7.4.1
platformdirs==4.2.0
# via virtualenv
pre-commit==3.7.0
pycparser==2.21
pycparser==2.22
# via cffi
pyproject-hooks==1.0.0
# via
@ -72,7 +76,7 @@ tomli==2.0.1
# build
# pip-tools
# pyproject-hooks
typing-extensions==4.10.0
typing-extensions==4.11.0
# via
# asgiref
# django-test-migrations

View File

@ -18,7 +18,7 @@ django-maintenance-mode # Shut down application while reloading
django-markdownify # Markdown rendering
django-mptt # Modified Preorder Tree Traversal
django-markdownify # Markdown rendering
django-money>=3.0.0,<3.5.0 # Django app for currency management # FIXED 2023-10-31 3.3.0 breaks due to https://github.com/django-money/django-money/issues/731
django-money>=3.0.0,<3.3.0 # Django app for currency management # FIXED 2023-10-31 3.3.0 breaks due to https://github.com/django-money/django-money/issues/731
django-mptt # Modified Preorder Tree Traversal
django-redis>=5.0.0 # Redis integration
django-q2 # Background task scheduling
@ -41,6 +41,7 @@ gunicorn # Gunicorn web server
pdf2image # PDF to image conversion
pillow # Image manipulation
pint==0.21 # Unit conversion # FIXED 2023-05-30 breaks tests https://github.com/matmair/InvenTree/actions/runs/5095665936/jobs/9160852560
pip-licenses # License information for installed packages
python-barcode[images] # Barcode generator
python-dotenv # Environment variable management
pyyaml>=6.0.1 # YAML parsing

View File

@ -1,6 +1,6 @@
# This file was autogenerated by uv via the following command:
# uv pip compile src/backend/requirements.in -o src/backend/requirements.txt --python-version=3.9 --no-strip-extras
asgiref==3.8.0
asgiref==3.8.1
# via
# django
# django-cors-headers
@ -87,7 +87,7 @@ django-cors-headers==4.3.1
django-crispy-forms==1.14.0
django-dbbackup==4.1.0
django-error-report-2==0.4.2
django-filter==24.1
django-filter==24.2
django-flags==5.0.13
django-formtools==2.5.1
django-ical==1.9.2
@ -96,11 +96,11 @@ django-js-asset==2.2.0
# via django-mptt
django-maintenance-mode==0.21.1
django-markdownify==0.9.3
django-money==3.4.1
django-money==3.2.0
django-mptt==0.16.0
django-otp==1.3.0
# via django-allauth-2fa
django-picklefield==3.1
django-picklefield==3.2
# via django-q2
django-q-sentry==0.1.6
django-q2==1.6.2
@ -121,12 +121,12 @@ djangorestframework==3.14.0
# djangorestframework-simplejwt
# drf-spectacular
djangorestframework-simplejwt[crypto]==5.3.1
drf-spectacular==0.27.1
drf-spectacular==0.27.2
dulwich==0.21.7
et-xmlfile==1.1.0
# via openpyxl
feedparser==6.0.11
fonttools[woff]==4.50.0
fonttools[woff]==4.51.0
# via weasyprint
googleapis-common-protos==1.63.0
# via
@ -141,7 +141,7 @@ icalendar==5.0.12
# via django-ical
idna==3.6
# via requests
importlib-metadata==6.11.0
importlib-metadata==7.0.0
# via
# django-q2
# markdown
@ -168,7 +168,7 @@ odfpy==1.4.1
# via tablib
openpyxl==3.1.2
# via tablib
opentelemetry-api==1.23.0
opentelemetry-api==1.24.0
# via
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
@ -178,43 +178,43 @@ opentelemetry-api==1.23.0
# opentelemetry-instrumentation-requests
# opentelemetry-instrumentation-wsgi
# opentelemetry-sdk
opentelemetry-exporter-otlp==1.23.0
opentelemetry-exporter-otlp-proto-common==1.23.0
opentelemetry-exporter-otlp==1.24.0
opentelemetry-exporter-otlp-proto-common==1.24.0
# via
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-grpc==1.23.0
opentelemetry-exporter-otlp-proto-grpc==1.24.0
# via opentelemetry-exporter-otlp
opentelemetry-exporter-otlp-proto-http==1.23.0
opentelemetry-exporter-otlp-proto-http==1.24.0
# via opentelemetry-exporter-otlp
opentelemetry-instrumentation==0.44b0
opentelemetry-instrumentation==0.45b0
# via
# opentelemetry-instrumentation-django
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-requests
# opentelemetry-instrumentation-wsgi
opentelemetry-instrumentation-django==0.44b0
opentelemetry-instrumentation-redis==0.44b0
opentelemetry-instrumentation-requests==0.44b0
opentelemetry-instrumentation-wsgi==0.44b0
opentelemetry-instrumentation-django==0.45b0
opentelemetry-instrumentation-redis==0.45b0
opentelemetry-instrumentation-requests==0.45b0
opentelemetry-instrumentation-wsgi==0.45b0
# via opentelemetry-instrumentation-django
opentelemetry-proto==1.23.0
opentelemetry-proto==1.24.0
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
opentelemetry-sdk==1.23.0
opentelemetry-sdk==1.24.0
# via
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
opentelemetry-semantic-conventions==0.44b0
opentelemetry-semantic-conventions==0.45b0
# via
# opentelemetry-instrumentation-django
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-requests
# opentelemetry-instrumentation-wsgi
# opentelemetry-sdk
opentelemetry-util-http==0.44b0
opentelemetry-util-http==0.45b0
# via
# opentelemetry-instrumentation-django
# opentelemetry-instrumentation-requests
@ -222,7 +222,7 @@ opentelemetry-util-http==0.44b0
packaging==24.0
# via gunicorn
pdf2image==1.17.0
pillow==10.2.0
pillow==10.3.0
# via
# django-stdimage
# pdf2image
@ -230,13 +230,16 @@ pillow==10.2.0
# qrcode
# weasyprint
pint==0.21
pip-licenses==4.4.0
prettytable==3.10.0
# via pip-licenses
protobuf==4.25.3
# via
# googleapis-common-protos
# opentelemetry-proto
py-moneyed==3.0
# via django-money
pycparser==2.21
pycparser==2.22
# via cffi
pydyf==0.9.0
# via weasyprint
@ -269,7 +272,7 @@ pyyaml==6.0.1
# tablib
qrcode[pil]==7.4.2
# via django-allauth-2fa
rapidfuzz==3.7.0
rapidfuzz==3.8.1
redis==5.0.3
# via django-redis
referencing==0.34.0
@ -283,13 +286,13 @@ requests==2.31.0
# django-allauth
# opentelemetry-exporter-otlp-proto-http
# requests-oauthlib
requests-oauthlib==1.4.0
requests-oauthlib==2.0.0
# via django-allauth
rpds-py==0.18.0
# via
# jsonschema
# referencing
sentry-sdk==1.43.0
sentry-sdk==1.44.1
# via django-q-sentry
setuptools==69.2.0
# via
@ -313,7 +316,7 @@ tinycss2==1.2.1
# bleach
# cssselect2
# weasyprint
typing-extensions==4.10.0
typing-extensions==4.11.0
# via
# asgiref
# drf-spectacular
@ -329,6 +332,8 @@ urllib3==2.2.1
# dulwich
# requests
# sentry-sdk
wcwidth==0.2.13
# via prettytable
weasyprint==61.2
# via django-weasyprint
webencodings==0.5.1

View File

@ -18,7 +18,7 @@
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@lingui/core": "^4.7.1",
"@lingui/react": "^4.7.2",
"@lingui/react": "^4.10.0",
"@mantine/carousel": "<7",
"@mantine/core": "<7",
"@mantine/dates": "<7",
@ -27,6 +27,7 @@
"@mantine/hooks": "<7",
"@mantine/modals": "<7",
"@mantine/notifications": "<7",
"@mantine/spotlight": "<7",
"@naisutech/react-tree": "^3.1.0",
"@sentry/react": "^7.109.0",
"@tabler/icons-react": "^3.1.0",
@ -37,17 +38,18 @@
"axios": "^1.6.7",
"dayjs": "^1.11.10",
"easymde": "^2.18.0",
"embla-carousel-react": "^8.0.0",
"embla-carousel-react": "^8.0.2",
"html5-qrcode": "^2.3.8",
"mantine-datatable": "<7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-grid-layout": "^1.4.4",
"react-hook-form": "^7.51.2",
"react-hook-form": "^7.51.3",
"react-is": "^18.2.0",
"react-router-dom": "^6.22.1",
"react-select": "^5.8.0",
"react-simplemde-editor": "^5.2.0",
"recharts": "^2.12.4",
"styled-components": "^5.3.6",
"zustand": "^4.5.1"
},
@ -56,8 +58,8 @@
"@babel/preset-react": "^7.23.3",
"@babel/preset-typescript": "^7.23.3",
"@lingui/cli": "^4.7.2",
"@lingui/macro": "^4.7.2",
"@playwright/test": "^1.41.2",
"@lingui/macro": "^4.10.0",
"@playwright/test": "^1.43.1",
"@types/node": "^20.12.3",
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.23",
@ -66,6 +68,7 @@
"@vitejs/plugin-react": "^4.2.1",
"babel-plugin-macros": "^3.1.0",
"nyc": "^15.1.0",
"rollup-plugin-license": "^3.3.1",
"typescript": "^5.3.3",
"vite": "^5.2.7",
"vite-plugin-babel-macros": "^1.0.6",

View File

@ -6,7 +6,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? 'github' : 'list',
reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
/* Configure projects for major browsers */
projects: [

View File

@ -0,0 +1,15 @@
import { t } from '@lingui/macro';
import { ActionIcon } from '@mantine/core';
import { spotlight } from '@mantine/spotlight';
import { IconCommand } from '@tabler/icons-react';
/**
* A button which opens the quick command modal
*/
export function SpotlightButton() {
return (
<ActionIcon onClick={() => spotlight.open()} title={t`Open spotlight`}>
<IconCommand />
</ActionIcon>
);
}

View File

@ -0,0 +1,12 @@
export const CHART_COLORS: string[] = [
'#ffa8a8',
'#8ce99a',
'#74c0fc',
'#ffe066',
'#63e6be',
'#ffc078',
'#d8f5a2',
'#66d9e8',
'#e599f7',
'#dee2e6'
];

View File

@ -23,9 +23,9 @@ import { getDetailUrl } from '../../functions/urls';
import { base_url } from '../../main';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { YesNoButton } from '../buttons/YesNoButton';
import { ProgressBar } from '../items/ProgressBar';
import { StylishText } from '../items/StylishText';
import { YesNoButton } from '../items/YesNoButton';
import { getModelInfo } from '../render/ModelType';
import { StatusRenderer } from '../render/StatusRenderer';

View File

@ -0,0 +1,26 @@
import { Badge } from '@mantine/core';
export type DetailsBadgeProps = {
color: string;
label: string;
size?: string;
visible?: boolean;
key?: any;
};
export default function DetailsBadge(props: DetailsBadgeProps) {
if (props.visible == false) {
return null;
}
return (
<Badge
key={props.key}
color={props.color}
variant="filled"
size={props.size ?? 'lg'}
>
{props.label}
</Badge>
);
}

View File

@ -66,6 +66,7 @@ export interface ApiFormProps {
pathParams?: PathParams;
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
fields?: ApiFormFieldSet;
focus?: string;
initialData?: FieldValues;
submitText?: string;
submitColor?: string;
@ -292,7 +293,48 @@ export function ApiForm({
});
initialDataQuery.refetch();
}
}, []);
}, [props.fetchInitialData]);
const isLoading = useMemo(
() =>
isFormLoading ||
initialDataQuery.isFetching ||
optionsLoading ||
isSubmitting ||
!props.fields,
[
isFormLoading,
initialDataQuery.isFetching,
isSubmitting,
props.fields,
optionsLoading
]
);
const [initialFocus, setInitialFocus] = useState<string>('');
// Update field focus when the form is loaded
useEffect(() => {
let focusField = props.focus ?? '';
if (!focusField) {
// If a focus field is not specified, then focus on the first available field
Object.entries(props.fields ?? {}).forEach(([fieldName, field]) => {
if (focusField || field.read_only || field.disabled || field.hidden) {
return;
}
focusField = fieldName;
});
}
if (isLoading || initialFocus == focusField) {
return;
}
form.setFocus(focusField);
setInitialFocus(focusField);
}, [props.focus, props.fields, form.setFocus, isLoading, initialFocus]);
const submitForm: SubmitHandler<FieldValues> = async (data) => {
setNonFieldErrors([]);
@ -392,22 +434,6 @@ export function ApiForm({
});
};
const isLoading = useMemo(
() =>
isFormLoading ||
initialDataQuery.isFetching ||
optionsLoading ||
isSubmitting ||
!props.fields,
[
isFormLoading,
initialDataQuery.isFetching,
isSubmitting,
props.fields,
optionsLoading
]
);
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
props.onFormError?.();
}, [props.onFormError]);

View File

@ -6,7 +6,7 @@ import { IconCheck } from '@tabler/icons-react';
import { useServerApiState } from '../../states/ApiState';
import { useLocalState } from '../../states/LocalState';
import { HostList } from '../../states/states';
import { EditButton } from '../items/EditButton';
import { EditButton } from '../buttons/EditButton';
import { HostOptionsForm } from './HostOptionsForm';
export function InstanceOptions({

View File

@ -188,7 +188,7 @@ export function ApiFormField({
return (
<TextInput
{...reducedDefinition}
ref={ref}
ref={field.ref}
id={fieldId}
type={definition.field_type}
value={value || ''}
@ -226,21 +226,14 @@ export function ApiFormField({
<NumberInput
{...reducedDefinition}
radius="sm"
ref={ref}
ref={field.ref}
id={fieldId}
value={numericalValue}
error={error?.message}
formatter={(value) => {
let v: any = parseFloat(value);
if (Number.isNaN(v) || !Number.isFinite(v)) {
return value;
}
return `${1 * v.toFixed()}`;
}}
precision={definition.field_type == 'integer' ? 0 : 10}
onChange={(value: number) => onChange(value)}
removeTrailingZeros
step={1}
/>
);
case 'choice':
@ -256,7 +249,7 @@ export function ApiFormField({
<FileInput
{...reducedDefinition}
id={fieldId}
ref={ref}
ref={field.ref}
radius="sm"
value={value}
error={error?.message}

View File

@ -52,6 +52,7 @@ export default function DateField({
<DateInput
id={fieldId}
radius="sm"
ref={field.ref}
type={undefined}
error={error?.message}
value={dateValue}

View File

@ -269,6 +269,7 @@ export function RelatedModelField({
<Select
id={fieldId}
value={currentValue}
ref={field.ref}
options={data}
filterOption={null}
onInputChange={(value: any) => {

View File

@ -13,6 +13,7 @@ export function Thumbnail({
src,
alt = t`Thumbnail`,
size = 20,
link,
text,
align
}: {
@ -21,9 +22,22 @@ export function Thumbnail({
size?: number;
text?: ReactNode;
align?: string;
link?: string;
}) {
const backup_image = '/static/img/blank_image.png';
const inner = useMemo(() => {
if (link) {
return (
<Anchor href={link} target="_blank">
{text}
</Anchor>
);
} else {
return text;
}
}, [link, text]);
return (
<Group align={align ?? 'left'} spacing="xs" noWrap={true}>
<ApiImage
@ -39,7 +53,7 @@ export function Thumbnail({
}
}}
/>
{text}
{inner}
</Group>
);
}

View File

@ -2,8 +2,8 @@ import { Trans } from '@lingui/macro';
import { Code, Flex, Group, Text } from '@mantine/core';
import { Link, To } from 'react-router-dom';
import { YesNoButton } from '../buttons/YesNoButton';
import { DetailDrawerLink } from '../nav/DetailDrawer';
import { YesNoButton } from './YesNoButton';
export function InfoItem({
name,

View File

@ -16,6 +16,10 @@ export interface MenuLinkItem {
docchildren?: React.ReactNode;
}
export type menuItemsCollection = {
[key: string]: MenuLinkItem;
};
function ConditionalDocTooltip({
item,
children

View File

@ -19,7 +19,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { apiUrl, useServerApiState } from '../../states/ApiState';
import { useLocalState } from '../../states/LocalState';
import { useUserState } from '../../states/UserState';
import { CopyButton } from '../items/CopyButton';
import { CopyButton } from '../buttons/CopyButton';
type AboutLookupRef = {
ref: string;

View File

@ -0,0 +1,96 @@
import { Trans, t } from '@lingui/macro';
import {
Accordion,
Alert,
Divider,
Group,
LoadingOverlay,
Space,
Stack,
Tabs,
Text
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { apiUrl } from '../../states/ApiState';
export function LicenceView(entries: Readonly<any[]>) {
return (
<Stack spacing="xs">
<Divider />
{entries?.length > 0 ? (
<Accordion variant="contained" defaultValue="-">
{entries?.map((entry: any, index: number) => (
<Accordion.Item key={entry.name} value={`entry-${index}`}>
<Accordion.Control>
<Group position="apart" grow>
<Text>{entry.name}</Text>
<Text>{entry.license}</Text>
<Space />
<Text>{entry.version}</Text>
</Group>
</Accordion.Control>
<Accordion.Panel style={{ whiteSpace: 'pre-line' }}>
{entry.licensetext || t`No license text available`}
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
) : (
<Text>
<Trans>No Information provided - this is likely a server issue</Trans>
</Text>
)}
</Stack>
);
}
export function LicenseModal() {
const { data, isFetching, isError } = useQuery({
queryKey: ['license'],
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.license))
.then((res) => res.data ?? {})
.catch(() => {})
});
const rspdata = !data ? [] : Object.keys(data ?? {});
return (
<Stack spacing="xs">
<Divider />
<LoadingOverlay visible={isFetching} />
{isFetching && (
<Text>
<Trans>Loading license information</Trans>
</Text>
)}
{isError ? (
<Alert color="red" title={t`Error`}>
<Text>
<Trans>Failed to fetch license information</Trans>
</Text>
</Alert>
) : (
<Tabs defaultValue={rspdata[0] ?? ''}>
<Tabs.List>
{rspdata.map((key) => (
<Tabs.Tab key={key} value={key}>
<Trans>{key} Packages</Trans>
</Tabs.Tab>
))}
</Tabs.List>
{rspdata.map((key) => (
<Tabs.Panel key={key} value={key}>
{LicenceView(data[key] ?? [])}
</Tabs.Panel>
))}
</Tabs>
)}
</Stack>
);
}

View File

@ -2,7 +2,7 @@ import { ActionIcon, Container, Group, Indicator, Tabs } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconBell, IconSearch } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useMatch, useNavigate, useParams } from 'react-router-dom';
import { api } from '../../App';
@ -10,7 +10,9 @@ import { navTabs as mainNavTabs } from '../../defaults/links';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { InvenTreeStyle } from '../../globalStyle';
import { apiUrl } from '../../states/ApiState';
import { ScanButton } from '../items/ScanButton';
import { useLocalState } from '../../states/LocalState';
import { ScanButton } from '../buttons/ScanButton';
import { SpotlightButton } from '../buttons/SpotlightButton';
import { MainMenu } from './MainMenu';
import { NavHoverMenu } from './NavHoverMenu';
import { NavigationDrawer } from './NavigationDrawer';
@ -19,8 +21,12 @@ import { SearchDrawer } from './SearchDrawer';
export function Header() {
const { classes } = InvenTreeStyle();
const [setNavigationOpen, navigationOpen] = useLocalState((state) => [
state.setNavigationOpen,
state.navigationOpen
]);
const [navDrawerOpened, { open: openNavDrawer, close: closeNavDrawer }] =
useDisclosure(false);
useDisclosure(navigationOpen);
const [
searchDrawerOpened,
{ open: openSearchDrawer, close: closeSearchDrawer }
@ -59,6 +65,18 @@ export function Header() {
refetchOnWindowFocus: false
});
// Sync Navigation Drawer state with zustand
useEffect(() => {
if (navigationOpen === navDrawerOpened) return;
setNavigationOpen(navDrawerOpened);
}, [navDrawerOpened]);
useEffect(() => {
if (navigationOpen === navDrawerOpened) return;
if (navigationOpen) openNavDrawer();
else closeNavDrawer();
}, [navigationOpen]);
return (
<div className={classes.layoutHeader}>
<SearchDrawer opened={searchDrawerOpened} onClose={closeSearchDrawer} />
@ -80,6 +98,7 @@ export function Header() {
<ActionIcon onClick={openSearchDrawer}>
<IconSearch />
</ActionIcon>
<SpotlightButton />
<ScanButton />
<ActionIcon onClick={openNotificationDrawer}>
<Indicator

Some files were not shown because too many files have changed in this diff Show More