Compare commits

...

72 Commits

Author SHA1 Message Date
Oliver 0dcb706f4c
Default stock currency (#10641) (#10644)
* Default stock currency (#10641)

* Fix for useStockFields

- Use default currency

* Ensure default currency is observed

* Specify field default

* Improve import (for ty)

* Update migration files

- Point currency fields to the correct default method

* Unit tests

- Ensure stock item gets correct default currency

* Cleaner generation of default currency value

- Return empty string during migratoins

* Update existing migrations

* Reduce noise

* Ignore "no-matching-overload" rule for ty

* Tweak money_kwargs

* Remove conflicting code

* Fix import

* Tweak currency_code_default
2025-10-21 16:29:24 +11:00
github-actions[bot] f8bcc3ec17
[UI] Bug fix for build output forms (#10640) (#10642)
(cherry picked from commit 2187a77153)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-21 08:22:50 +11:00
Oliver bc42450e0a
Bump software version to 1.0.8 (#10635) 2025-10-20 16:26:26 +11:00
github-actions[bot] fa698e7e2b
Fixes for SITE_URL validity checks (#10619) (#10634)
* [docker] Allow HTTPS port to be specified for Caddy proxy

* Fix naming collision for INVENTREE_WEB_PORT

* Push InvenTree version first

* Adjust Caddyfile

- Change backup server

* Fix docstring

* Tweak for site URL check:

- Ignore port if SITE_LAX_PROTOCOL_CHECK is set
- Invert logic for readability

* Additional checks for port mismatch

* Adjust middleware checks

- Allow for less strict checking of CSRF_TRUSTED_ORIGINS

* Slight refactor

(cherry picked from commit f9ce9e20b2)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-20 16:19:30 +11:00
github-actions[bot] c59fd55a00
Setup: allow more python versions (#10615) (#10616)
* extend supported python versions

* bump max python

(cherry picked from commit 874be9920d)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-10-19 07:09:49 +11:00
github-actions[bot] 486e338b0b
BOM updates (#10611) (#10612)
* BOM updates

- Allow variants of substititute parts to be allocated
- Closes https://github.com/inventree/InvenTree/issues/10606

* Check self.allow_variants

* Add comment

(cherry picked from commit a7c4f2adba)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-18 12:06:16 +11:00
Oliver eb32546824
Bump software version to 1.0.7 (#10591) 2025-10-17 09:18:55 +11:00
github-actions[bot] cc508a544c
Dashboard item fix (#10596) (#10597)
* Fix for "subscribed categories" dashboard item

* Tweak filter display

* Tweak filter for "Subscribed Parts"

(cherry picked from commit 485aa6324c)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-16 23:17:11 +11:00
github-actions[bot] c471a1cd38
Order labels (#10588) (#10590)
* Add label actions for build orders

* Support other order types

(cherry picked from commit e040d99665)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-16 07:12:27 +11:00
github-actions[bot] 1249ae3a84
[UI] Fix broken dashboard link (#10577) (#10582)
Ref: https://github.com/inventree/inventree/issues/10548
(cherry picked from commit e0559bb2b4)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-14 12:07:24 +11:00
github-actions[bot] 4fe949811d
[docs] Troubleshooting Guide (#10574) (#10575)
* [docs] troubleshooting guide

Adds a rough troubleshooting guide

* Add docs for showing docker logs

(cherry picked from commit 71b9373f44)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-13 19:45:01 +11:00
github-actions[bot] afdb4090bf
Fix missing closing </div> tag (#10572) (#10573)
(cherry picked from commit f9bdad975f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-13 17:50:13 +11:00
github-actions[bot] ae2cf931a5
[UI] Remove placeholder text (#10569) (#10570)
- Remove TODO entry
- Placeholder when adding external build order support

(cherry picked from commit a466926aef)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-13 14:26:22 +11:00
github-actions[bot] 6fa54c0e0e
[UI] Fix stock actions (#10566) (#10568)
* Clear selected records when search term changes

* Clear selection after performing stock actions

(cherry picked from commit f22417fd1f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-13 13:37:49 +11:00
github-actions[bot] ff79ab87e5
[UI] About InvenTree Tweak (#10565) (#10567)
- Hide version entries without data

(cherry picked from commit ea868b3179)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-13 13:23:53 +11:00
github-actions[bot] 9fe290b01f
[UI] Adjust login error messages (#10556) (#10564)
* Adjust config template

- Don't hard-code cookie mode into template
- Revert to the "default" values (which are the same)

* [ui] better feedback on login error

- Show error code, at least

* Revert removed code

* Adjust playwright tests

(cherry picked from commit 6badc0148f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-13 12:31:31 +11:00
github-actions[bot] a63fe64aa2
Installer docs (#10552) (#10563)
* Better formatting

* Tweak setup docs

* Add information on process control and logs

* Fix typo

* Change `cli` to `invoke`

* Update docs/docs/start/installer.md



* Update docs/docs/start/installer.md



* Remove available commands section from installer.md

Removed section on available commands for InvenTree services.

---------


(cherry picked from commit 30e91eb226)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
2025-10-13 10:01:31 +11:00
github-actions[bot] b6acbf0a48
fix(installer): make VERSION information accessible in invoke calls (#10558) (#10562)
* implement version loading in more contexts
closes #10554

* factor version file out

* ensure results do not contain new strings

(cherry picked from commit 6327707c0e)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-10-13 09:13:02 +11:00
github-actions[bot] 4a83b98cd9
Catch error during auto-migrations (#10553) (#10560)
- Prevent process interlock
- Prevent migration check if already running migrations

(cherry picked from commit 067cb1fccb)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-13 09:01:04 +11:00
github-actions[bot] fe1cc56e94
Fix "override pricing" display (#10545) (#10547)
- Use specified currency values
- Closes #10537

(cherry picked from commit 4edbe9bab1)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-11 12:12:11 +11:00
github-actions[bot] ef35591110
Update docs for running playwright tests (#10544) (#10546)
(cherry picked from commit 0dbb0306a5)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-11 10:53:44 +11:00
github-actions[bot] df7817735d
[UI] Prevent warning flash (#10540) (#10541)
* [UI] Prevent warning flash

- Display warning element after a delay

* Support no-javascript

(cherry picked from commit 1a171b5705)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-10 12:55:44 +11:00
Oliver c3ea3a5566
Bump InvenTree software version to 1.0.6 (#10524) 2025-10-07 12:24:16 +11:00
github-actions[bot] be3e2b4e01
feat(backend): Improve error message on INVE-7 (#10518) (#10523)
* feat(backend): Improve error message on INVE-7

* change wording

* fix test

* another fix

(cherry picked from commit 13845c69da)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-10-07 11:35:25 +11:00
github-actions[bot] 3fc2db6c3a
fix: correct admin credential msg (#10521) (#10522)
* fix installers final message

* adjust text

(cherry picked from commit ac6028b871)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-10-07 11:25:15 +11:00
github-actions[bot] 79325c96d0
[docs] BOM import (#10517) (#10520)
- Add brief section about importing BOM data

(cherry picked from commit 492613952c)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-07 11:10:12 +11:00
github-actions[bot] 62425f1f3b
fix: package distribution (#10515) (#10519)
* show warning text

* fix formatting

* try to gather site-url correctly

* protect envs that are set in the config

* raise issue if they appear

* prefer app setting over file config

* preserve setting if injected via envs

(cherry picked from commit c83a9b01ce)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-10-07 09:13:33 +11:00
Oliver 200c1dfe06
Bump software version to 1.0.5 (#10509) 2025-10-06 22:19:19 +11:00
Oliver 8c0b35ba69
Bump InvenTree software version to 1.0.4 (#10508) 2025-10-06 22:10:48 +11:00
github-actions[bot] b1d0f3cb9e
fix(backend): ensure internal notifications are created in a valid format (#10502) (#10506)
Closes #10435

(cherry picked from commit 96a5291766)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-10-06 22:10:26 +11:00
github-actions[bot] 555b72305c
Update docs regarding `INVENTREE_AUTO_UPDATE` (#10505) (#10507)
(cherry picked from commit 363c5fd2c6)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-06 22:07:56 +11:00
Matthias Mair 9819442697
bump contianer / dev dependencies too (#10472) (#10496)
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-06 21:57:34 +11:00
github-actions[bot] f901eec771
Fix rendering for custom status values (#10492) (#10494)
- Improve field lookup priority

(cherry picked from commit b370d54394)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-05 22:39:04 +11:00
github-actions[bot] 3044cee011
Tweak docker setup (#10490) (#10493)
- Set default forwarding values

(cherry picked from commit 946d4358c3)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-05 22:12:35 +11:00
github-actions[bot] 17be1fbb43
feat(ui/choice field): select old content on click, auto select first row (#10485) (#10491)
closes #10468, #10470

(cherry picked from commit 4635ea9443)

Co-authored-by: Hamza Ali <github+hhhapz@hamza.sh>
2025-10-05 20:00:08 +11:00
github-actions[bot] d2aa15257b
feat(backend): releax protocol check (#10454) (#10460)
* feat(backend): releax protocol check

* add changelog entry

* fix variable name

---------


(cherry picked from commit 7638092ab8)

Co-authored-by: Matthias Mair <code@mjmair.com>
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-02 21:56:10 +10:00
github-actions[bot] 6ff28da48f
chore(deps): bump django from 4.2.24 to 4.2.25 in /src/backend (#10448) (#10455)
* chore(deps): bump django from 4.2.24 to 4.2.25 in /src/backend

Bumps [django](https://github.com/django/django) from 4.2.24 to 4.2.25.
- [Commits](https://github.com/django/django/compare/4.2.24...4.2.25)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 4.2.25
  dependency-type: direct:production
...



* fix style

---------




(cherry picked from commit b8b1fabc50)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
2025-10-02 20:43:23 +10:00
github-actions[bot] bd62363b9a
[UI] Fix total price for Purchase Order table (#10450) (#10452)
- Closes https://github.com/inventree/InvenTree/issues/10442

(cherry picked from commit bfd32c9864)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-10-02 11:41:24 +10:00
github-actions[bot] acc85672e1
Improve printing actions (#10430) (#10432)
- Ensure correct items are specified

(cherry picked from commit 867cd24088)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-30 09:52:43 +10:00
Oliver 3e9fc94d63
Bump software version to 1.0.3 (#10425) 2025-09-29 15:22:52 +10:00
github-actions[bot] 1d2700fa6f
[UI] Consume tracked (#10422) (#10424)
* Prevent manual consumption of tracked stock

* Prevent manual consuming of trackable items

(cherry picked from commit b0a60ed963)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-29 14:01:43 +10:00
github-actions[bot] 9c170e6ed3
Fix locale formatting for calendar display (#10418) (#10420)
- Cannot accept underscore

(cherry picked from commit bcc386aecf)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-29 11:31:49 +10:00
github-actions[bot] 542a75ce58
[UI] Enable printing of build lines (#10403) (#10410)
* [UI] Enable printing of build lines

- Closes https://github.com/inventree/InvenTree/issues/10402

* Prevent cell click action

(cherry picked from commit e897222e07)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-27 14:30:43 +10:00
github-actions[bot] 06750358d6
[bug] Auto allocate bugfix (#10398) (#10407)
* Fix "unallocated_quantity" calculation

- Take "consumed" quantity into account also

* Account for consumed quantity in:

- build.is_fully_allocated
- build.is_overallocated

* Additional unit tests

- Ensure the new calculations work properly

* Adjust API filter

* Try splitting query

* Another fix

* Try ExpressionWrapper

* Change order of operations?

* Refactor

* Adjust filtering strategy

* Change ordering

* Use Max wrapper

* Add comments

(cherry picked from commit 6fdc6b3a8c)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-27 10:23:06 +10:00
github-actions[bot] 6ccc4544be
Fix typo (#10400) (#10401)
(cherry picked from commit 52be30eef5)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-26 13:47:10 +10:00
github-actions[bot] ac324cff14
Tweak build line table (#10397) (#10399)
- Show allocated quantity even if fully consumed
- Handles edge case where fully consumed but more stock allocated

(cherry picked from commit 1670523dab)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-26 11:37:09 +10:00
github-actions[bot] 98b1678402
fix: correct user deletion (#10385) (#10386)
* Ensure all user sessions are cleared

* remove double warning text on user delete

(cherry picked from commit 4794d69687)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-09-24 10:30:29 +10:00
github-actions[bot] da3ebacf6b
Hide "consume" action when not viewed from build page (#10378) (#10379)
(cherry picked from commit a7b1b9d523)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-23 21:08:54 +10:00
github-actions[bot] 24b2401f6a
Handle null user case (#10362) (#10365)
(cherry picked from commit 2f357587bc)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-20 13:32:45 +10:00
github-actions[bot] d12c335032
Support import of "choice" fields (#10361) (#10364)
- Perform reverse lookup of display value

(cherry picked from commit bbfdcdce73)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-20 12:56:53 +10:00
Oliver eae580deaf
Bump software version to 1.0.2 (#10360) 2025-09-20 10:15:49 +10:00
github-actions[bot] 6207aa6c32
Improved error handling (#10352) (#10354)
- Closes https://github.com/inventree/InvenTree/issues/10338

(cherry picked from commit f4333bd83f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-19 18:43:00 +10:00
github-actions[bot] 5ff7ce4703
fix(backend): better siteurl testing in middleware (#10335) (#10353)
* fix(backend): simplify siteurl testing

* add multi-site test

* pass off site_url check if more than one trusted origin is set

* split up testing

* add temporary debug info

* fix test enviorment

(cherry picked from commit 4b0acad518)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-09-19 18:42:33 +10:00
github-actions[bot] 0350623866
[UI] Display Stock link (#10350) (#10351)
- Display "link" for stock item

(cherry picked from commit 843dd92901)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-19 10:50:31 +10:00
github-actions[bot] bb576b16d8
fix bug I introduced with automatic EmailAddress creation for LDAP users (#10347) (#10349)
(cherry picked from commit fd57b5354b)

Co-authored-by: Jacob Felknor <jacobfelknor073@gmail.com>
2025-09-19 08:14:03 +10:00
github-actions[bot] 9705ea90c2
UI panels fix (#10341) (#10342)
* Tweak sample plugin

* Re-fetch panels when instance changes

* Unit test fix

(cherry picked from commit df0e27bed2)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-18 13:31:43 +10:00
github-actions[bot] 39dc2b17fd
Fix for RenderStockItem (#10336) (#10337)
- Handle case where serial number is empty string

(cherry picked from commit f057247fc1)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-17 13:47:45 +10:00
github-actions[bot] 00646b0891
Exclude field from stock-item import (#10333) (#10334)
(cherry picked from commit a6e555708f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-17 10:05:27 +10:00
Oliver 2b7627a940
Bump dummy plugin version (#10330)
* Bump dummy plugin version

- Required, else newer versions fail CI

* Bump version number to 1.0.1
2025-09-16 11:45:33 +10:00
github-actions[bot] 048ece4797
Fix user defined radius (#10327) (#10328)
- Observe correct radius values
- Closes https://github.com/inventree/InvenTree/issues/10322

(cherry picked from commit 5727999d4d)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-16 10:25:28 +10:00
Oliver 0feb542899
Update InvenTree software version to 1.0.0 (#10319)
Bump to 1.0.0 in preparation for release
2025-09-14 21:22:38 +10:00
github-actions[bot] 4f9c42cbc2
feat(frontend): Add samples for dashboard (#10306) (#10317)
* feat(forntend): Add sampels to dashboard
Closes #9990

* add sessions storage to disable sample dash once cleared/removed

(cherry picked from commit 9679e58212)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-09-14 20:54:11 +10:00
Oliver f8310c268c
1.0.0 rc0 (#10312)
- Release candidate for testing release process
2025-09-12 09:56:00 +10:00
github-actions[bot] 91c134f752
Fix link rendering for RenderInlineModel (#10311) (#10313)
(cherry picked from commit f3ec708a28)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-12 09:47:00 +10:00
Oliver a57676faef
Release version checker (#10304) (#10310)
* Enhance version check regex

* Refactor version_check.py

- Account for non-standard release tags (rc / dev / etc)
- Refactor code for extracting version info
- Add argparse support
- Update qc_checks.yaml

* Enhanced debug output

* Stringify and strip

* Display version tuple in log

* Tweak CI logs
2025-09-12 09:18:31 +10:00
github-actions[bot] 0de265d954
chore(deps): bump django from 4.2.23 to 4.2.24 in /src/backend (#10300) (#10309)
* chore(deps): bump django from 4.2.23 to 4.2.24 in /src/backend

Bumps [django](https://github.com/django/django) from 4.2.23 to 4.2.24.
- [Commits](https://github.com/django/django/compare/4.2.23...4.2.24)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 4.2.24
  dependency-type: direct:production
...



* fix style

---------




(cherry picked from commit ccfd1c4bf8)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
2025-09-12 09:06:28 +10:00
github-actions[bot] f4fdad5d48
Refactor (backend): Improve BuildItemList API filters (#10279) (#10303)
* refactor(stock): improve StockList api filters

* update PR numver in api_version

* Update src/backend/InvenTree/InvenTree/api_version.py



* Fix MySQL test failure caused by self-referential FK constraint in StockItem

* Data import fix (#10298)

* Data import fix

- Improved error handling

* Tweak frontend display of errors

* chore(deps-dev): bump vite from 6.3.5 to 6.3.6 in /src/frontend (#10297)

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: direct:development
...




---------





(cherry picked from commit 8adfa234bb)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Reza <50555450+Reza98Sh@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-11 09:38:33 +10:00
github-actions[bot] 73d6da8c13
Data import fix (#10298) (#10299)
* Data import fix

- Improved error handling

* Tweak frontend display of errors

(cherry picked from commit 9df896cf7a)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-10 11:05:26 +10:00
github-actions[bot] c108fd016d
fix(backend): re-add active plugins to anon status (#10282) (#10295)
* this was disabled due to wrong feedback by me, common debug tools do not work because of this

* patch tests

* make mfa test more robust

(cherry picked from commit 2c22686520)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-09-09 08:25:37 +10:00
github-actions[bot] 2c95c3db29
[UI] Fixes for part stock history (#10293) (#10294)
- Correct default data ordering
- Fix rendering for date labels

(cherry picked from commit b65a3f985d)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-09 08:06:35 +10:00
github-actions[bot] 8571a42981
Improved formatting (#10284) (#10292)
- Use decimal formatting functions for more places in the UI

(cherry picked from commit 2ac381b4dc)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-09 06:41:56 +10:00
Oliver a916e501c3
Bump version number to 1.0.0 (#10283)
- Prep for upcoming release
2025-09-09 06:32:48 +10:00
106 changed files with 1697 additions and 716 deletions

View File

@ -10,6 +10,7 @@ tagged branch:
""" """
import argparse
import itertools import itertools
import json import json
import os import os
@ -23,7 +24,93 @@ REPO = os.getenv('GITHUB_REPOSITORY', 'inventree/inventree')
GITHUB_API_URL = os.getenv('GITHUB_API_URL', 'https://api.github.com') GITHUB_API_URL = os.getenv('GITHUB_API_URL', 'https://api.github.com')
def get_existing_release_tags(include_prerelease=True): def get_src_dir() -> Path:
"""Return the path to the InvenTree source directory."""
here = Path(__file__).parent.absolute()
src_dir = here.joinpath('..', '..', 'src', 'backend', 'InvenTree', 'InvenTree')
if not src_dir.exists():
raise FileNotFoundError(
f"Could not find InvenTree source directory: '{src_dir}'"
)
return src_dir
def get_inventree_version() -> str:
"""Return the InvenTree version string."""
src_dir = get_src_dir()
version_file = src_dir.joinpath('version.py')
if not version_file.exists():
raise FileNotFoundError(
f"Could not find InvenTree version file: '{version_file}'"
)
with open(version_file, encoding='utf-8') as f:
text = f.read()
# Extract the InvenTree software version
results = re.findall(r"""INVENTREE_SW_VERSION = '(.*)'""", text)
if len(results) != 1:
raise ValueError(f'Could not find INVENTREE_SW_VERSION in {version_file}')
return results[0]
def get_api_version() -> str:
"""Return the InvenTree API version string."""
src_dir = get_src_dir()
api_version_file = src_dir.joinpath('api_version.py')
if not api_version_file.exists():
raise FileNotFoundError(
f"Could not find InvenTree API version file: '{api_version_file}'"
)
with open(api_version_file, encoding='utf-8') as f:
text = f.read()
# Extract the InvenTree software version
results = re.findall(r"""INVENTREE_API_VERSION = (.*)""", text)
if len(results) != 1:
raise ValueError(
f'Could not find INVENTREE_API_VERSION in {api_version_file}'
)
return results[0].strip().strip('"').strip("'")
def version_number_to_tuple(version_string: str) -> tuple[int, int, int, str]:
"""Validate a version number string, and convert to a tuple of integers.
e.g. 1.1.0
e.g. 1.1.0 dev
e.g. 1.2.3-rc2
"""
pattern = r'^(\d+)\.(\d+)\.(\d+)[\s-]?(.*)?$'
match = re.match(pattern, version_string)
if not match or len(match.groups()) < 3:
raise ValueError(
f"Version string '{version_string}' did not match required pattern"
)
result = tuple(int(x) for x in match.groups()[:3])
# Add optional prerelease tag
if len(match.groups()) > 3:
result += (match.groups()[3] or '',)
else:
result += ('',)
return result
def get_existing_release_tags(include_prerelease: bool = True):
"""Request information on existing releases via the GitHub API.""" """Request information on existing releases via the GitHub API."""
# Check for github token # Check for github token
token = os.getenv('GITHUB_TOKEN', None) token = os.getenv('GITHUB_TOKEN', None)
@ -46,16 +133,16 @@ def get_existing_release_tags(include_prerelease=True):
for release in data: for release in data:
tag = release['tag_name'].strip() tag = release['tag_name'].strip()
match = re.match(r'^.*(\d+)\.(\d+)\.(\d+).*$', tag)
if len(match.groups()) != 3: version_tuple = version_number_to_tuple(tag)
print(f"Version '{tag}' did not match expected pattern")
continue
if not include_prerelease and release['prerelease']: if len(version_tuple) >= 4 and version_tuple[3]:
continue # Skip prerelease tags
if not include_prerelease:
print('-- skipping prerelease tag:', tag)
continue
tags.append([int(x) for x in match.groups()]) tags.append(tag)
return tags return tags
@ -67,15 +154,7 @@ def check_version_number(version_string, allow_duplicate=False):
""" """
print(f"Checking version '{version_string}'") print(f"Checking version '{version_string}'")
# Check that the version string matches the required format version_tuple = version_number_to_tuple(version_string)
match = re.match(r'^(\d+)\.(\d+)\.(\d+)(?: dev)?$', version_string)
if not match or len(match.groups()) != 3:
raise ValueError(
f"Version string '{version_string}' did not match required pattern"
)
version_tuple = [int(x) for x in match.groups()]
# Look through the existing releases # Look through the existing releases
existing = get_existing_release_tags(include_prerelease=False) existing = get_existing_release_tags(include_prerelease=False)
@ -83,35 +162,67 @@ def check_version_number(version_string, allow_duplicate=False):
# Assume that this is the highest release, unless told otherwise # Assume that this is the highest release, unless told otherwise
highest_release = True highest_release = True
# A non-standard tag cannot be the 'highest' release
if len(version_tuple) >= 4 and version_tuple[3]:
highest_release = False
print(f"-- Version tag '{version_string}' cannot be the highest release")
for release in existing: for release in existing:
if release == version_tuple and not allow_duplicate: if version_string == release and not allow_duplicate:
raise ValueError(f"Duplicate release '{version_string}' exists!") raise ValueError(f"Duplicate release '{version_string}' exists!")
if release > version_tuple: release_tuple = version_number_to_tuple(release)
if release_tuple > version_tuple:
highest_release = False highest_release = False
print(f'Found newer release: {release!s}') print(f'Found newer release: {release!s}')
if highest_release:
print(f"-- Version '{version_string}' is the highest release")
return highest_release return highest_release
if __name__ == '__main__': if __name__ == '__main__':
parser = argparse.ArgumentParser(description='InvenTree Version Check')
parser.add_argument(
'--show-version',
action='store_true',
help='Print the InvenTree version and exit',
)
parser.add_argument(
'--show-api-version',
action='store_true',
help='Print the InvenTree API version and exit',
)
parser.add_argument(
'--decrement-api',
type=str,
default='false',
help='Decrement the API version by 1 and print',
)
args = parser.parse_args()
inventree_version = get_inventree_version()
inventree_api_version = int(get_api_version())
if args.show_version:
print(inventree_version)
sys.exit(0)
if args.show_api_version:
if str(args.decrement_api).strip().lower() == 'true':
inventree_api_version -= 1
print(inventree_api_version)
sys.exit(0)
# Ensure that we are running in GH Actions # Ensure that we are running in GH Actions
if os.environ.get('GITHUB_ACTIONS', '') != 'true': if os.environ.get('GITHUB_ACTIONS', '') != 'true':
print('This script is intended to be run within a GitHub Action!') print('This script is intended to be run within a GitHub Action!')
sys.exit(1) sys.exit(1)
if 'only_version' in sys.argv: print('Running InvenTree version check...')
here = Path(__file__).parent.absolute()
version_file = here.joinpath(
'..', '..', 'src', 'backend', 'InvenTree', 'InvenTree', 'api_version.py'
)
text = version_file.read_text()
results = re.findall(r"""INVENTREE_API_VERSION = (.*)""", text)
# If 2. args is true lower the version number by 1
if len(sys.argv) > 2 and sys.argv[2] == 'true':
results[0] = str(int(results[0]) - 1)
print(results[0])
exit(0)
# GITHUB_REF_TYPE may be either 'branch' or 'tag' # GITHUB_REF_TYPE may be either 'branch' or 'tag'
GITHUB_REF_TYPE = os.environ['GITHUB_REF_TYPE'] GITHUB_REF_TYPE = os.environ['GITHUB_REF_TYPE']
@ -127,26 +238,10 @@ if __name__ == '__main__':
print(f'GITHUB_REF_TYPE: {GITHUB_REF_TYPE}') print(f'GITHUB_REF_TYPE: {GITHUB_REF_TYPE}')
print(f'GITHUB_BASE_REF: {GITHUB_BASE_REF}') print(f'GITHUB_BASE_REF: {GITHUB_BASE_REF}')
here = Path(__file__).parent.absolute() print(
version_file = here.joinpath( f"InvenTree Version: '{inventree_version}' - {version_number_to_tuple(inventree_version)}"
'..', '..', 'src', 'backend', 'InvenTree', 'InvenTree', 'version.py'
) )
print(f"InvenTree API Version: '{inventree_api_version}'")
version = None
with open(version_file, encoding='utf-8') as f:
text = f.read()
# Extract the InvenTree software version
results = re.findall(r"""INVENTREE_SW_VERSION = '(.*)'""", text)
if len(results) != 1:
print(f'Could not find INVENTREE_SW_VERSION in {version_file}')
sys.exit(1)
version = results[0]
print(f"InvenTree Version: '{version}'")
# Check version number and look for existing versions # Check version number and look for existing versions
# If a release is found which matches the current tag, throw an error # If a release is found which matches the current tag, throw an error
@ -161,7 +256,9 @@ if __name__ == '__main__':
if GITHUB_BASE_REF == 'stable': if GITHUB_BASE_REF == 'stable':
allow_duplicate = True allow_duplicate = True
highest_release = check_version_number(version, allow_duplicate=allow_duplicate) highest_release = check_version_number(
inventree_version, allow_duplicate=allow_duplicate
)
# Determine which docker tag we are going to use # Determine which docker tag we are going to use
docker_tags = None docker_tags = None
@ -171,8 +268,10 @@ if __name__ == '__main__':
version_tag = GITHUB_REF.split('/')[-1] version_tag = GITHUB_REF.split('/')[-1]
print(f"Checking requirements for tagged release - '{version_tag}':") print(f"Checking requirements for tagged release - '{version_tag}':")
if version_tag != version: if version_tag != inventree_version:
print(f"Version number '{version}' does not match tag '{version_tag}'") print(
f"Version number '{inventree_version}' does not match tag '{version_tag}'"
)
sys.exit sys.exit
docker_tags = [version_tag, 'stable'] if highest_release else [version_tag] docker_tags = [version_tag, 'stable'] if highest_release else [version_tag]
@ -180,10 +279,11 @@ if __name__ == '__main__':
elif GITHUB_REF_TYPE == 'branch': elif GITHUB_REF_TYPE == 'branch':
# Otherwise we know we are targeting the 'master' branch # Otherwise we know we are targeting the 'master' branch
docker_tags = ['latest'] docker_tags = ['latest']
highest_release = False
else: else:
print('Unsupported branch / version combination:') print('Unsupported branch / version combination:')
print(f'InvenTree Version: {version}') print(f'InvenTree Version: {inventree_version}')
print('GITHUB_REF_TYPE:', GITHUB_REF_TYPE) print('GITHUB_REF_TYPE:', GITHUB_REF_TYPE)
print('GITHUB_BASE_REF:', GITHUB_BASE_REF) print('GITHUB_BASE_REF:', GITHUB_BASE_REF)
print('GITHUB_REF:', GITHUB_REF) print('GITHUB_REF:', GITHUB_REF)
@ -193,7 +293,7 @@ if __name__ == '__main__':
print('Docker tags could not be determined') print('Docker tags could not be determined')
sys.exit(1) sys.exit(1)
print(f"Version check passed for '{version}'!") print(f"Version check passed for '{inventree_version}'!")
print(f"Docker tags: '{docker_tags}'") print(f"Docker tags: '{docker_tags}'")
target_repos = [REPO.lower(), f'ghcr.io/{REPO.lower()}'] target_repos = [REPO.lower(), f'ghcr.io/{REPO.lower()}']

View File

@ -164,8 +164,8 @@ jobs:
API: ${{ needs.paths-filter.outputs.api }} API: ${{ needs.paths-filter.outputs.api }}
run: | run: |
pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1 pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1
version="$(python3 .github/scripts/version_check.py only_version ${API} 2>&1)" version="$(python3 .github/scripts/version_check.py --show-api-version --decrement-api=${API} 2>&1)"
echo "Version: $version" echo "API Version: $version"
url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml" url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml"
echo "URL: $url" echo "URL: $url"
code=$(curl -s -o api.yaml $url --write-out '%{http_code}' --silent) code=$(curl -s -o api.yaml $url --write-out '%{http_code}' --silent)
@ -198,8 +198,8 @@ jobs:
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true' if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true'
run: | run: |
pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1 pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1
version="$(python3 .github/scripts/version_check.py only_version 2>&1)" version="$(python3 .github/scripts/version_check.py --show-api-version 2>&1)"
echo "Version: $version" echo "API Version: $version"
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Extract settings / tags - name: Extract settings / tags
run: invoke int.export-definitions --basedir docs run: invoke int.export-definitions --basedir docs

View File

@ -22,9 +22,9 @@ before:
- contrib/packager.io/before.sh - contrib/packager.io/before.sh
dependencies: dependencies:
- curl - curl
- "python3.9 | python3.10 | python3.11" - "python3.9 | python3.10 | python3.11 | python3.12 | python3.13 | python3.14"
- "python3.9-venv | python3.10-venv | python3.11-venv" - "python3.9-venv | python3.10-venv | python3.11-venv | python3.12-venv | python3.13-venv | python3.14-venv"
- "python3.9-dev | python3.10-dev | python3.11-dev" - "python3.9-dev | python3.10-dev | python3.11-dev | python3.12-dev | python3.13-dev | python3.14-dev"
- python3-pip - python3-pip
- python3-cffi - python3-cffi
- python3-brotli - python3-brotli

View File

@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Changed site URL check to allow protocol mismatches if `INVENTREE_SITE_LAX_PROTOCOL` is set to `True` (default) in [#10454](https://github.com/inventree/InvenTree/pull/10454)
### Removed ### Removed

View File

@ -13,6 +13,11 @@ INVENTREE_SITE_URL="http://inventree.localhost"
#INVENTREE_SITE_URL="http://192.168.1.2" # You can specify a local IP address here #INVENTREE_SITE_URL="http://192.168.1.2" # You can specify a local IP address here
#INVENTREE_SITE_URL="https://inventree.my-domain.com" # Or a public domain name (which you control) #INVENTREE_SITE_URL="https://inventree.my-domain.com" # Or a public domain name (which you control)
# InvenTree proxy forwarding settings
INVENTREE_USE_X_FORWARDED_HOST=True
INVENTREE_USE_X_FORWARDED_PORT=True
INVENTREE_USE_X_FORWARDED_PROTO=True
# Specify the location of the external data volume # Specify the location of the external data volume
# By default, placed in local directory 'inventree-data' # By default, placed in local directory 'inventree-data'
INVENTREE_EXT_VOLUME=./inventree-data INVENTREE_EXT_VOLUME=./inventree-data
@ -23,7 +28,8 @@ INVENTREE_LOG_LEVEL=WARNING
# Enable custom plugins? # Enable custom plugins?
INVENTREE_PLUGINS_ENABLED=True INVENTREE_PLUGINS_ENABLED=True
# Run migrations automatically? # Run database migrations automatically?
# Note: This does not negate the need to run "invoke update"
INVENTREE_AUTO_UPDATE=True INVENTREE_AUTO_UPDATE=True
# InvenTree superuser account details # InvenTree superuser account details

View File

@ -30,9 +30,9 @@
} }
# The default server address is configured in the .env file # The default server address is configured in the .env file
# If not specified, the default address is used - http://inventree.localhost # If not specified, the proxy listens for all http/https traffic
# If you need to listen on multiple addresses, or use a different port, you can modify this section directly # If you need to listen on multiple addresses, or use a different port, you can modify this section directly
{$INVENTREE_SITE_URL:http://inventree.localhost} { {$INVENTREE_SITE_URL:"http://, https://"} {
import log_common inventree import log_common inventree
encode gzip encode gzip

View File

@ -101,6 +101,7 @@ services:
restart: unless-stopped restart: unless-stopped
# caddy acts as reverse proxy and static file server # caddy acts as reverse proxy and static file server
# You can adjust the ports that the proxy listens on via the .env file
# https://hub.docker.com/_/caddy # https://hub.docker.com/_/caddy
inventree-proxy: inventree-proxy:
container_name: inventree-proxy container_name: inventree-proxy
@ -109,8 +110,8 @@ services:
depends_on: depends_on:
- inventree-server - inventree-server
ports: ports:
- ${INVENTREE_WEB_PORT:-80}:80 - ${INVENTREE_HTTP_PORT:-80}:80
- 443:443 - ${INVENTREE_HTTPS_PORT:-443}:443
env_file: env_file:
- .env - .env
volumes: volumes:

View File

@ -1,12 +1,12 @@
# This file was autogenerated by uv via the following command: # This file was autogenerated by uv via the following command:
# uv pip compile contrib/container/requirements.in -o contrib/container/requirements.txt --python-version=3.11 --no-strip-extras --generate-hashes -b src/backend/requirements.txt # uv pip compile contrib/container/requirements.in -o contrib/container/requirements.txt --python-version=3.11 --no-strip-extras --generate-hashes -b src/backend/requirements.txt
asgiref==3.9.1 \ asgiref==3.9.2 \
--hash=sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142 \ --hash=sha256:0b61526596219d70396548fc003635056856dba5d0d086f86476f10b33c75960 \
--hash=sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c --hash=sha256:a0249afacb66688ef258ffe503528360443e2b9a8d8c4581b6ebefa58c841ef1
# via django # via django
django==4.2.23 \ django==4.2.25 \
--hash=sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4 \ --hash=sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311 \
--hash=sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803 --hash=sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c
# via # via
# -r contrib/container/requirements.in # -r contrib/container/requirements.in
# django-auth-ldap # django-auth-ldap
@ -51,76 +51,73 @@ packaging==25.0 \
# via # via
# gunicorn # gunicorn
# mariadb # mariadb
psycopg[binary, pool]==3.2.9 \ psycopg[binary, pool]==3.2.10 \
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \ --hash=sha256:0bce99269d16ed18401683a8569b2c5abd94f72f8364856d56c0389bcd50972a \
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700 --hash=sha256:ab5caf09a9ec42e314a21f5216dbcceac528e0e05142e42eea83a3b28b320ac3
# via -r contrib/container/requirements.in # via -r contrib/container/requirements.in
psycopg-binary==3.2.9 \ psycopg-binary==3.2.10 \
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \ --hash=sha256:037dc92fc7d3f2adae7680e17216934c15b919d6528b908ac2eb52aecc0addcf \
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \ --hash=sha256:0738320a8d405f98743227ff70ed8fac9670870289435f4861dc640cef4a61d3 \
--hash=sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b \ --hash=sha256:0c23e88e048bbc33f32f5a35981707c9418723d469552dd5ac4e956366e58492 \
--hash=sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab \ --hash=sha256:0c2b95e83fda70ed2b0b4fadd8538572e4a4d987b721823981862d1ab56cc760 \
--hash=sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4 \ --hash=sha256:14bcbcac0cab465d88b2581e43ec01af4b01c9833e663f1352e05cb41be19e44 \
--hash=sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78 \ --hash=sha256:183a59cbdcd7e156669577fd73a9e917b1ee664e620f1e31ae138d24c7714693 \
--hash=sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4 \ --hash=sha256:1b29285474e3339d0840e1b5079fdb0481914108f92ec62de0c87ae333c60b24 \
--hash=sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a \ --hash=sha256:1dee2f4d2adc9adacbfecf8254bd82f6ac95cff707e1b9b99aa721cd1ef16b47 \
--hash=sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2 \ --hash=sha256:1f6982609b8ff8fcd67299b67cd5787da1876f3bb28fedd547262cfa8ddedf94 \
--hash=sha256:1f1736d5b21f69feefeef8a75e8d3bf1f0a1e17c165a7488c3111af9d6936e91 \ --hash=sha256:2028073fc12cd70ba003309d1439c0c4afab4a7eee7653b8c91213064fffe12b \
--hash=sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb \ --hash=sha256:20384985fbc650c09a547a13c6d7f91bb42020d38ceafd2b68b7fc4a48a1f160 \
--hash=sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523 \ --hash=sha256:299834cce3eec0c48aae5a5207fc8f0c558fd65f2ceab1a36693329847da956b \
--hash=sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4 \ --hash=sha256:29b6bb87959515bc8b6abef10d8d23a9a681f03e48e9f0c8adb4b9fb7fa73f11 \
--hash=sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351 \ --hash=sha256:3234605839e7d7584bd0a20716395eba34d368a5099dafe7896c943facac98fc \
--hash=sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd \ --hash=sha256:37b42b2f5f58df1f07a5df1b0c2bcc9bd3b9c105e2e988923bfa47aa4ae967da \
--hash=sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a \ --hash=sha256:3bb4046973264ebc8cb7e20a83882d68577c1f26a6f8ad4fe52e4468cd9a8eee \
--hash=sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee \ --hash=sha256:3e115930af2f38f4bbb5f1b61b598ceb802f091c1592c0fe0571c796b714b89a \
--hash=sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a \ --hash=sha256:42ee399c2613b470a87084ed79b06d9d277f19b0457c10e03a4aef7059097abc \
--hash=sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074 \ --hash=sha256:43d803fb4e108a67c78ba58f3e6855437ca25d56504cae7ebbfbd8fce9b59247 \
--hash=sha256:418f52b77b715b42e8ec43ee61ca74abc6765a20db11e8576e7f6586488a266f \ --hash=sha256:447afc326cbc95ed67c0cd27606c0f81fa933b830061e096dbd37e08501cb3de \
--hash=sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2 \ --hash=sha256:470594d303928ab72a1ffd179c9c7bde9d00f76711d6b0c28f8a46ddf56d9807 \
--hash=sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734 \ --hash=sha256:484d2b1659afe0f8f1cef5ea960bb640e96fa864faf917086f9f833f5c7a8034 \
--hash=sha256:4df22ec17390ec5ccb38d211fb251d138d37a43344492858cea24de8efa15003 \ --hash=sha256:50130c0d1a2a01ec3d41631df86b6c1646c76718be000600a399dc1aad80b813 \
--hash=sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093 \ --hash=sha256:5334a61a00ccb722f0b28789e265c7a273cfd10d5a1ed6bf062686fbb71e7032 \
--hash=sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40 \ --hash=sha256:5369202e0e764193eac311b5a337d8cd58b1e23b822ddb7a559ed9f683d97623 \
--hash=sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe \ --hash=sha256:55b14f2402be027fe1568bc6c4d75ac34628ff5442a70f74137dadf99f738e3b \
--hash=sha256:5918c0fab50df764812f3ca287f0d716c5c10bedde93d4da2cefc9d40d03f3aa \ --hash=sha256:6220d6efd6e2df7b67d70ed60d653106cd3b70c5cb8cbe4e9f0a142a5db14015 \
--hash=sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2 \ --hash=sha256:62590dd113d10cd9c08251cb80b32e2e8aaf01ece04a700322e776b1d216959f \
--hash=sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7 \ --hash=sha256:646048f46192c8d23786cc6ef19f35b7488d4110396391e407eca695fdfe9dcd \
--hash=sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724 \ --hash=sha256:6fe450a98a0788b721b1b8302f0ba9be6eca82faf74bf7a86d794cd6484c7e27 \
--hash=sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0 \ --hash=sha256:70bb7f665587dfd79e69f48b34efe226149454d7aab138ed22d5431d703de2f6 \
--hash=sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186 \ --hash=sha256:725843fd444075cc6c9989f5b25ca83ac68d8d70b58e1f476fbb4096975e43cc \
--hash=sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5 \ --hash=sha256:764a5b9b40ad371c55dfdf95374d89e44a82fd62272d4fceebea0adb8930e2fb \
--hash=sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b \ --hash=sha256:7950ff79df7a453ac8a7d7a74694055b6c15905b0a2b6e3c99eb59c51a3f9bf7 \
--hash=sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6 \ --hash=sha256:7fa1626225a162924d2da0ff4ef77869f7a8501d320355d2732be5bf2dda6138 \
--hash=sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9 \ --hash=sha256:810f65b9ef1fe9dddb5c05937884ea9563aaf4e1a2c3d138205231ed5f439511 \
--hash=sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786 \ --hash=sha256:8390db6d2010ffcaf7f2b42339a2da620a7125d37029c1f9b72dfb04a8e7be6f \
--hash=sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7 \ --hash=sha256:84f7e8c5e5031db342ae697c2e8fb48cd708ba56990573b33e53ce626445371d \
--hash=sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21 \ --hash=sha256:8923487c3898c65e1450847e15d734bb2e6adbd2e79d2d1dd5ad829a1306bdc0 \
--hash=sha256:7b617b81f08ad8def5edd110de44fd6d326f969240cc940c6f6b3ef21fe9c59f \ --hash=sha256:89440355d1b163b11dc661ae64a5667578aab1b80bbf71ced90693d88e9863e1 \
--hash=sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8 \ --hash=sha256:8b45e65383da9c4a42a56f817973e521e893f4faae897fe9f1a971f9fe799742 \
--hash=sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87 \ --hash=sha256:8f4ae059c6c9e491cdc3f39f9fc4f09373ef281c6cc381499269dcff21abafc9 \
--hash=sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07 \ --hash=sha256:8fa2efaf5e2f8c289a185c91c80a624a8f97aa17fbedcbc68f373d089b332afd \
--hash=sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff \ --hash=sha256:901729188b3fd5625970650ca1167786847dee0b92930c2858724d1a5e25dee1 \
--hash=sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e \ --hash=sha256:9c9f2728488ac5848acdbf14bb4fde50f8ba783cbf3c19e9abd506741389fa7f \
--hash=sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f \ --hash=sha256:a024b3ee539a475cbc59df877c8ecdd6f8552a1b522b69196935bc26dc6152fb \
--hash=sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891 \ --hash=sha256:a1d4e4d309049e3cb61269652a3ca56cb598da30ecd7eb8cea561e0d18bc1a43 \
--hash=sha256:a4d76e28df27ce25dc19583407f5c6c6c2ba33b443329331ab29b6ef94c8736d \ --hash=sha256:a28f24a7b68456bd31209b027a5b04304d37eb1d622ef847bf8c47933218a738 \
--hash=sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04 \ --hash=sha256:a5a81104d88780018005fe17c37fa55b4afbb6dd3c205963cc56c025d5f1cc32 \
--hash=sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d \ --hash=sha256:a92ff1c2cd79b3966d6a87e26ceb222ecd5581b5ae4b58961f126af806a861ed \
--hash=sha256:b2d7a6646d41228e9049978be1f3f838b557a1bde500b919906d54c4390f5086 \ --hash=sha256:ab1c6d761c4ee581016823dcc02f29b16ad69177fcbba88a9074c924fc31813e \
--hash=sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748 \ --hash=sha256:ac0365398947879c9827b319217096be727da16c94422e0eb3cf98c930643162 \
--hash=sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695 \ --hash=sha256:b34c278a58aa79562afe7f45e0455b1f4cad5974fc3d5674cc5f1f9f57e97fc5 \
--hash=sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a \ --hash=sha256:bd3676a04970cf825d2c771b0c147f91182c5a3653e0dbe958e12383668d0f79 \
--hash=sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e \ --hash=sha256:bf30dcf6aaaa8d4779a20d2158bdf81cc8e84ce8eee595d748a7671c70c7b890 \
--hash=sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d \ --hash=sha256:d2fe9eaa367f6171ab1a21a7dcb335eb2398be7f8bb7e04a20e2260aedc6f782 \
--hash=sha256:cf789be42aea5752ee396d58de0538d5fcb76795c85fb03ab23620293fb81b6f \ --hash=sha256:d557a94cd6d2e775b3af6cc0bd0ff0d9d641820b5cc3060ccf1f5ca2bf971217 \
--hash=sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc \ --hash=sha256:d5c6a66a76022af41970bf19f51bc6bf87bd10165783dd1d40484bfd87d6b382 \
--hash=sha256:e0f05b9dafa5670a7503abc715af081dbbb176a8e6770de77bccaeb9024206c5 \ --hash=sha256:d7d05174276bb403b8a57e01b857d96b0ac2a6879c5ce06a5cac2d1115763081 \
--hash=sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8 \ --hash=sha256:d922fdd49ed17c558b6b2f9ae2054c3d0cced2a34e079ce5a41c86904d0203f7 \
--hash=sha256:eac3a6e926421e976c1c2653624e1294f162dc67ac55f9addbe8f7b8d08ce603 \ --hash=sha256:db0eb06a19e4c64a08db0db80875ede44939af6a2afc281762c338fad5d6e547 \
--hash=sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b \ --hash=sha256:e037aac8dc894d147ef33056fc826ee5072977107a3fdf06122224353a057598
--hash=sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95 \
--hash=sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1 \
--hash=sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb
# via psycopg # via psycopg
psycopg-pool==3.2.6 \ psycopg-pool==3.2.6 \
--hash=sha256:0f92a7817719517212fbfe2fd58b8c35c1850cdd2a80d36b581ba2085d9148e5 \ --hash=sha256:0f92a7817719517212fbfe2fd58b8c35c1850cdd2a80d36b581ba2085d9148e5 \
@ -141,60 +138,80 @@ python-ldap==3.4.4 \
# via # via
# -r contrib/container/requirements.in # -r contrib/container/requirements.in
# django-auth-ldap # django-auth-ldap
pyyaml==6.0.2 \ pyyaml==6.0.3 \
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 \ --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e \ --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5 \ --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee \ --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5 \ --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 \ --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a \ --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf \ --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 \ --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8 \ --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85 \ --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19 \ --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \ --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a \ --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \ --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317 \ --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c \ --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631 \ --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d \ --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \ --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e \ --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b \ --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8 \ --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476 \ --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706 \ --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \ --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 \ --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b \ --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083 \ --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 \ --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425 \ --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e \ --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f \ --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725 \ --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab \ --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774 \ --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725 \ --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5 \ --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d \ --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290 \ --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44 \ --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed \ --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4 \ --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via -r contrib/container/requirements.in # via -r contrib/container/requirements.in
setuptools==80.9.0 \ setuptools==80.9.0 \
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \ --hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
@ -204,32 +221,32 @@ sqlparse==0.5.3 \
--hash=sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272 \ --hash=sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272 \
--hash=sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca --hash=sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca
# via django # via django
typing-extensions==4.14.1 \ typing-extensions==4.15.0 \
--hash=sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76 --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via # via
# psycopg # psycopg
# psycopg-pool # psycopg-pool
uv==0.8.6 \ uv==0.8.22 \
--hash=sha256:0b524de39f317bd8733c38cf100b6f8091d44e06b23f7752523ad1ad1454ede3 \ --hash=sha256:2a436b941b6e79fe1e1065b705a5689d72210f4367cbe885e19910cbcde2e4a1 \
--hash=sha256:132e73f1e9fe05edc6c06c00416f7c721c48298786fd7293be6c584793170bbc \ --hash=sha256:36c7aecdb0044caf15ace00da00af172759c49c832f0017b7433d80f46552cd3 \
--hash=sha256:1913f5627c57076c88dd38b0173bdb006ae9b8dbd92b1798a1acc9d744c1a7cc \ --hash=sha256:4a982bdd5d239dd6dd2b4219165e209c75af1e1819730454ee46d65b3ccf77a3 \
--hash=sha256:2ac28509db2e52613a59264bdb150d13274ed13e5b305f7e274da8cd83033985 \ --hash=sha256:58b6fb191a04b922dc3c8fea6660f58545a651843d7d0efa9ae69164fca9e05d \
--hash=sha256:37227aaf1e41c7eda3d7f0028e747a2a2eed3f3506b0adc121a4366e8281115b \ --hash=sha256:6706b782ad75662df794e186d16b9ffa4946d57c88f21d0eadfd43425794d1b0 \
--hash=sha256:4d4e042f6bd9f143094051a05de758684028f451e563846cbc0c6f505b530cca \ --hash=sha256:7350c5f82d9c38944e6466933edcf96a90e0cb85eae5c0e53a5bc716d6f62332 \
--hash=sha256:4e81380549151e34ae96d56499438444ba58591ca9f2fc6ba0a867152601849e \ --hash=sha256:7378127cbd6ebce8ba6d9bdb88aa8ea995b579824abb5ec381c63b3a123a43be \
--hash=sha256:57a98367bfad38e870e1a8a6626464796ffcee6e937d429fbd7b25ddf46bb36f \ --hash=sha256:7e761ca7df8a0059b3fae6bc2c1db24583fa00b016e35bd22a5599d7084471a7 \
--hash=sha256:5b201ebc1c5c76c3a415fa4edcb25a0e06263d2255319d6d52275c775e926e23 \ --hash=sha256:89944e99b04cc8542cb5931306f1c593f00c9d6f2b652fffc4d84d12b915f911 \
--hash=sha256:6acdc77099906ba64bc1b725bef973c10905d7e9596d1b25f271db772bc9e8e4 \ --hash=sha256:8ea724ae9f15c0cb4964e9e2e1b21df65c56ae02a54dc1d8a6ea44a52d819268 \
--hash=sha256:7796acc3c5b84d5ee5e10cc6cf92eb61c19f6551855d0aa89ef5925e4a371fbf \ --hash=sha256:8efec4ef5acddc35f0867998c44e0b15fc4dace1e4c26d01443871a2fbb04bf6 \
--hash=sha256:7c1f48279ff61940143c78b969094e13324988eabcfcd4799f4350d9d36c1d48 \ --hash=sha256:9757f0b0c7d296f1e354db442ed0ce39721c06d11635ce4ee6638c5e809a9cb4 \
--hash=sha256:993af2c295856c5ca053678a8dadc11ce2f85485513ed1568c16e98d5dfa88bf \ --hash=sha256:9eb3b4abfa25e07d7e1bb4c9bb8dbbdd51878356a37c3c4a2ece3d68d4286f28 \
--hash=sha256:c9de4adac36a62e4bddd959ce65fb4bb09b0cbfd95946d50390f2a9c186ecb9c \ --hash=sha256:aefa0cb27a86d2145ca9290a1e99c16a17ea26a4f14a89fb7336bc19388427cc \
--hash=sha256:d96ff3a1d06a6a00ed94dfb2996228153b3b5bfc892174b7556216ab872a91b1 \ --hash=sha256:b1fdffc2e71892ce648b66317e478fe8884d0007e20cfa582fff3dcea588a450 \
--hash=sha256:deab2ce32d2dd7a1c0de459aa23470c60feb0ea24e67c9c5c5988d8bf4eb4a09 \ --hash=sha256:cda349c9ea53644d8d9ceae30db71616b733eb5330375ab4259765aef494b74e \
--hash=sha256:e35cc1ef79d3dce2b6aeffbfb280d02d5ad741d4ca07874bdf0a4d85c841d9de \ --hash=sha256:d6a33bd5309f8fb77d9fc249bb17f77a23426e6153e43b03ca1cd6640f0a423d \
--hash=sha256:ee67acf1b211be2cfbeaec16cde13c8325810d32ff85963a9dedd1f9d7c61ef7 \ --hash=sha256:e6e1289c411d43e0ca245f46e76457f3807de646d90b656591b6cf46348bed5c \
--hash=sha256:fdceb1ef554df0ddc620bfe83fdcf740829e489c62f78ba1f089abd62c71c63e --hash=sha256:f6ded9bacb31441d788afca397b8b884ebc2e70f903bea0a38806194be4b249c
# via -r contrib/container/requirements.in # via -r contrib/container/requirements.in
wheel==0.45.1 \ wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \

View File

@ -5,7 +5,7 @@
Color_Off='\033[0m' Color_Off='\033[0m'
On_Red='\033[41m' On_Red='\033[41m'
PYTHON_FROM=9 PYTHON_FROM=9
PYTHON_TO=12 PYTHON_TO=14
function detect_docker() { function detect_docker() {
if [ -n "$(grep docker </proc/1/cgroup)" ]; then if [ -n "$(grep docker </proc/1/cgroup)" ]; then
@ -166,6 +166,18 @@ function detect_envs() {
export INVENTREE_DB_PASSWORD=$(jq -r '.[].database.PASSWORD' <<< ${INVENTREE_CONF_DATA}) export INVENTREE_DB_PASSWORD=$(jq -r '.[].database.PASSWORD' <<< ${INVENTREE_CONF_DATA})
export INVENTREE_DB_HOST=$(jq -r '.[].database.HOST' <<< ${INVENTREE_CONF_DATA}) export INVENTREE_DB_HOST=$(jq -r '.[].database.HOST' <<< ${INVENTREE_CONF_DATA})
export INVENTREE_DB_PORT=$(jq -r '.[].database.PORT' <<< ${INVENTREE_CONF_DATA}) export INVENTREE_DB_PORT=$(jq -r '.[].database.PORT' <<< ${INVENTREE_CONF_DATA})
# Parse site URL if not already set
if [ -z "${INVENTREE_SITE_URL}" ]; then
# Try to read out the app config
if [ -n "$(inventree config:get INVENTREE_SITE_URL)" ]; then
echo "# POI03| Getting site URL from app config"
export INVENTREE_SITE_URL=$(inventree config:get INVENTREE_SITE_URL)
else
echo "# POI03| Getting site URL from config file"
export INVENTREE_SITE_URL=$(jq -r '.[].site_url' <<< ${INVENTREE_CONF_DATA})
fi
fi
else else
echo "# POI03| No config file found: ${INVENTREE_CONFIG_FILE}, using envs or defaults" echo "# POI03| No config file found: ${INVENTREE_CONFIG_FILE}, using envs or defaults"
@ -190,6 +202,8 @@ function detect_envs() {
export INVENTREE_DB_HOST=${INVENTREE_DB_HOST:-samplehost} export INVENTREE_DB_HOST=${INVENTREE_DB_HOST:-samplehost}
export INVENTREE_DB_PORT=${INVENTREE_DB_PORT:-123456} export INVENTREE_DB_PORT=${INVENTREE_DB_PORT:-123456}
export INVENTREE_SITE_URL=${INVENTREE_SITE_URL}
export SETUP_CONF_LOADED=true export SETUP_CONF_LOADED=true
fi fi
@ -209,6 +223,7 @@ function detect_envs() {
fi fi
echo "# POI03| INVENTREE_DB_HOST=${INVENTREE_DB_HOST}" echo "# POI03| INVENTREE_DB_HOST=${INVENTREE_DB_HOST}"
echo "# POI03| INVENTREE_DB_PORT=${INVENTREE_DB_PORT}" echo "# POI03| INVENTREE_DB_PORT=${INVENTREE_DB_PORT}"
echo "# POI03| INVENTREE_SITE_URL=${INVENTREE_SITE_URL}"
} }
function create_initscripts() { function create_initscripts() {
@ -313,7 +328,7 @@ function update_or_install() {
# Run update as app user # Run update as app user
echo "# POI12| Updating InvenTree" echo "# POI12| Updating InvenTree"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && pip install wheel" sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && pip install wheel"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && invoke update | sed -e 's/^/# POI12| u | /;'" sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && set -e && invoke update | sed -e 's/^/# POI12| u | /;'"
# Make sure permissions are correct again # Make sure permissions are correct again
echo "# POI12| Set permissions for data dir and media: ${DATA_DIR}" echo "# POI12| Set permissions for data dir and media: ${DATA_DIR}"
@ -373,10 +388,15 @@ function set_site() {
# Check if INVENTREE_SITE_URL in inventree config # Check if INVENTREE_SITE_URL in inventree config
if [ -z "$(inventree config:get INVENTREE_SITE_URL)" ]; then if [ -z "$(inventree config:get INVENTREE_SITE_URL)" ]; then
echo "# POI14| Setting up InvenTree site URL" # Prefer current INVENTREE_SITE_URL if set
inventree config:set INVENTREE_SITE_URL=http://${INVENTREE_IP} if [ -n "${INVENTREE_SITE_URL}" ]; then
inventree config:set INVENTREE_SITE_URL=${INVENTREE_SITE_URL}
else
echo "# POI14| Setting up InvenTree site URL"
inventree config:set INVENTREE_SITE_URL=http://${INVENTREE_IP}
fi
else else
echo "# POI14| Site URL already set - skipping" echo "# POI14| Site URL already set to '$INVENTREE_SITE_URL' - skipping"
fi fi
} }
@ -385,11 +405,16 @@ function final_message() {
echo -e "####################################################################################" echo -e "####################################################################################"
echo -e "This InvenTree install uses nginx, the settings for the webserver can be found in" echo -e "This InvenTree install uses nginx, the settings for the webserver can be found in"
echo -e "${SETUP_NGINX_FILE}" echo -e "${SETUP_NGINX_FILE}"
echo -e "Try opening InvenTree with either\nhttp://localhost/ or http://${INVENTREE_IP}/\n" echo -e "Try opening InvenTree with any of \n${INVENTREE_SITE_URL} , http://localhost/ or http://${INVENTREE_IP}/ \n"
echo -e "Admin user data:" # Print admin user data only if set
echo -e " Email: ${INVENTREE_ADMIN_EMAIL}" if ["${INVENTREE_ADMIN_USER}" ]; then
echo -e " Username: ${INVENTREE_ADMIN_USER}" echo -e "Admin user data:"
echo -e " Password: ${INVENTREE_ADMIN_PASSWORD}" echo -e " Email: ${INVENTREE_ADMIN_EMAIL}"
echo -e " Username: ${INVENTREE_ADMIN_USER}"
echo -e " Password: ${INVENTREE_ADMIN_PASSWORD}"
else
echo -e "No admin set during this operation - depending on the deployment method a admin user might have been created with an initial password saved in `${SETUP_ADMIN_PASSWORD_FILE}`"
fi
echo -e "####################################################################################" echo -e "####################################################################################"
} }

View File

@ -14,7 +14,7 @@ echo "# POI01| Importing functions"
echo "# POI01| Functions imported" echo "# POI01| Functions imported"
# Envs that should be passed to setup commands # Envs that should be passed to setup commands
export SETUP_ENVS=PATH,APP_HOME,INVENTREE_MEDIA_ROOT,INVENTREE_STATIC_ROOT,INVENTREE_BACKUP_DIR,INVENTREE_SITE_URL,INVENTREE_PLUGINS_ENABLED,INVENTREE_PLUGIN_FILE,INVENTREE_CONFIG_FILE,INVENTREE_SECRET_KEY_FILE,INVENTREE_DB_ENGINE,INVENTREE_DB_NAME,INVENTREE_DB_USER,INVENTREE_DB_PASSWORD,INVENTREE_DB_HOST,INVENTREE_DB_PORT,INVENTREE_ADMIN_USER,INVENTREE_ADMIN_EMAIL,INVENTREE_ADMIN_PASSWORD,SETUP_NGINX_FILE,SETUP_ADMIN_PASSWORD_FILE,SETUP_NO_CALLS,SETUP_DEBUG,SETUP_EXTRA_PIP,SETUP_PYTHON,SETUP_ADMIN_NOCREATION export SETUP_ENVS=PATH,APP_HOME,INVENTREE_MEDIA_ROOT,INVENTREE_STATIC_ROOT,INVENTREE_BACKUP_DIR,INVENTREE_PLUGINS_ENABLED,INVENTREE_PLUGIN_FILE,INVENTREE_CONFIG_FILE,INVENTREE_SECRET_KEY_FILE,INVENTREE_DB_ENGINE,INVENTREE_DB_NAME,INVENTREE_DB_USER,INVENTREE_DB_PASSWORD,INVENTREE_DB_HOST,INVENTREE_DB_PORT,INVENTREE_ADMIN_USER,INVENTREE_ADMIN_EMAIL,INVENTREE_ADMIN_PASSWORD,INVENTREE_SITE_URL,SETUP_NGINX_FILE,SETUP_ADMIN_PASSWORD_FILE,SETUP_NO_CALLS,SETUP_DEBUG,SETUP_EXTRA_PIP,SETUP_PYTHON,SETUP_ADMIN_NOCREATION
# Get the envs # Get the envs
detect_local_env detect_local_env

View File

@ -6,7 +6,7 @@ echo "# PRI01| Running preinstall script - start - $(date)"
PATH=${APP_HOME}/env/bin:${APP_HOME}/:/sbin:/bin:/usr/sbin:/usr/bin: PATH=${APP_HOME}/env/bin:${APP_HOME}/:/sbin:/bin:/usr/sbin:/usr/bin:
# Envs that should be passed to setup commands # Envs that should be passed to setup commands
export SETUP_ENVS=PATH,APP_HOME,INVENTREE_MEDIA_ROOT,INVENTREE_STATIC_ROOT,INVENTREE_BACKUP_DIR,INVENTREE_PLUGINS_ENABLED,INVENTREE_PLUGIN_FILE,INVENTREE_CONFIG_FILE,INVENTREE_SECRET_KEY_FILE,INVENTREE_DB_ENGINE,INVENTREE_DB_NAME,INVENTREE_DB_USER,INVENTREE_DB_PASSWORD,INVENTREE_DB_HOST,INVENTREE_DB_PORT,INVENTREE_ADMIN_USER,INVENTREE_ADMIN_EMAIL,INVENTREE_ADMIN_PASSWORD,SETUP_NGINX_FILE,SETUP_ADMIN_PASSWORD_FILE,SETUP_NO_CALLS,SETUP_DEBUG,SETUP_EXTRA_PIP,SETUP_PYTHON export SETUP_ENVS=PATH,APP_HOME,INVENTREE_MEDIA_ROOT,INVENTREE_STATIC_ROOT,INVENTREE_BACKUP_DIR,INVENTREE_PLUGINS_ENABLED,INVENTREE_PLUGIN_FILE,INVENTREE_CONFIG_FILE,INVENTREE_SECRET_KEY_FILE,INVENTREE_DB_ENGINE,INVENTREE_DB_NAME,INVENTREE_DB_USER,INVENTREE_DB_PASSWORD,INVENTREE_DB_HOST,INVENTREE_DB_PORT,INVENTREE_ADMIN_USER,INVENTREE_ADMIN_EMAIL,INVENTREE_ADMIN_PASSWORD,INVENTREE_SITE_URL,SETUP_NGINX_FILE,SETUP_ADMIN_PASSWORD_FILE,SETUP_NO_CALLS,SETUP_DEBUG,SETUP_EXTRA_PIP,SETUP_PYTHON
if test -f "${APP_HOME}/env/bin/pip"; then if test -f "${APP_HOME}/env/bin/pip"; then
# Check if clear-generated is available # Check if clear-generated is available

View File

@ -73,6 +73,16 @@ sudo npx playwright install-deps
npx playwright install npx playwright install
``` ```
### Dataset
The playwright tests assume that the [InvenTree test dataset](../demo.md#local-setup) is loaded into the InvenTree installation. This dataset provides a known set of data that the tests can run against.
Before running the frontend tests, ensure that a clean copy of the test dataset is loaded into your InvenTree instance, by running the following command:
```bash
invoke dev.setup-test -i
```
### Running Tests ### Running Tests
To run the tests locally, in an interactive editor, you can use the following command: To run the tests locally, in an interactive editor, you can use the following command:

View File

@ -6,6 +6,10 @@ title: FAQ
Below is a list of frequently asked questions. If you are having issues with InvenTree please consult this list first! Below is a list of frequently asked questions. If you are having issues with InvenTree please consult this list first!
Also, you can refer to our [GitHub page](https://github.com/inventree/inventree/issues) for known issues and bug reports - perhaps your issue has already been reported!
If you cannot resolve the issue, please refer to the [troubleshooting guide](#troubleshooting-guide).
## Installation Issues ## Installation Issues
### Installing on Windows ### Installing on Windows
@ -191,3 +195,76 @@ This means that either:
- The docker user does not have write permission to the specified directory - The docker user does not have write permission to the specified directory
In either case, ensure that the directory is available *on your local machine* and the user account has the required permissions. In either case, ensure that the directory is available *on your local machine* and the user account has the required permissions.
## Troubleshooting Guide
If you are struggling with an issue which is not covered in the FAQ above, please refer to the following troubleshooting steps.
Even if you cannot immediately resolve the issue, the information below will be very useful when reporting the issue on GitHub.
### Run Update Step
If you have recently installed or updated your InvenTree instance, make sure that you have run the `invoke update` command, which will perform any required database migrations and other update tasks. This is a *critical step* after any system update.
#### Docker
If you are have installed InvenTree via Docker:
```bash
docker-compose exec inventree-server invoke update
```
#### Installer
If you have installed InvenTree via the installer script:
```bash
inventree run invoke update
```
### Logged Errors
Look at the logged error reports in the admin section - you will need to be an administrator to access this section. If a critical error has occurred, it may be logged here.
### GitHub Issues
Before raising a new issue, please check the [GitHub issues page](https://github.com/inventree/inventree) for reported issues. If your issue is a common one, it may already have been reported - and perhaps even resolved!
### Web Browser Console
If you are experiencing issues with the web interface, you can open the developer console in your web browser to check for error messages. This may vary slightly between web browsers, but there is a wealth of information available online if you need help.
Once the developer console is open, there are two places to check for error messages:
#### Console Tab
Navigate to the *Console* tab in the developer tools. Any error messages will be highlighted in red. They may indicate either a rendering issue, or a problem with a network request.
#### Network Tab
Navigate to the *Network* tab in the developer tools. Check for any requests which have a status code of 400 or greater (indicating an error). Click on the request to see more information about the error.
### Server Logs
Finally, you can check the server logs for error messages. The location of the server logs will depend on how you have installed InvenTree.
#### Docker
If you are using Docker, you can view the server logs with the following command:
To display logs for all running containers:
```bash
docker compose logs
```
Refer to the [docker documentation](./start/docker_install.md#viewing-logs) for more information.
#### Installer
If you are using the installer script, you can view the server logs with the following command:
```bash
inventree logs
```
Refer to the [installer documentation](./start/installer.md#viewing-logs) for more information.

View File

@ -88,11 +88,17 @@ Note that inherited BOM Line Items only flow "downwards" in the variant inherita
## BOM Creation ## BOM Creation
BOMs can be created manually, by adjusting individual line items, or by upload an existing BOM file. BOMs can be created manually, by adjusting individual line items, or by uploading (importing) an existing BOM file.
### Importing a BOM
BOM data can be imported from an existing file (such as CSV or Excel) from the *BOM* panel for a particular part/assembly. This process is a special case of the more general [data import process](../settings/import.md).
At the top of the *BOM* panel, click on the {{ icon("file-arrow-left", color="green", title="Import BOM Data") }} icon to open the import dialog.
### Add BOM Item ### Add BOM Item
To manually add a BOM item, navigate to the part/assembly detail page then click on the "BOM" tab. On top of the tab view, click on the {{ icon("edit", color="blue", title="Edit") }} icon then, after the page reloads, click on the {{ icon("plus-circle") }} icon. To manually add a BOM item, navigate to the part/assembly detail page then click on the *BOM* panel tab. On top of the *BOM* view, click on the {{ icon("edit", color="blue", title="Edit") }} icon then, after the page reloads, click on the {{ icon("plus-circle") }} icon.
The `Create BOM Item` form will be displayed: The `Create BOM Item` form will be displayed:

View File

@ -9,9 +9,12 @@ Errors - These are critical errors which should be addressed as soon as possible
#### INVE-E1 #### INVE-E1
**No frontend included - Backend/web** **No frontend included - Backend/web**
Only stable / production releases of InvenTree include the frontend panel. This is both a measure of resource-saving and attack surface reduction. If you want to use the frontend panel, you can either:″ Only stable / production releases of InvenTree include the frontend panel. This is both a measure of resource-saving and attack surface reduction.
If you want to use the frontend panel, you can either:
- use a docker image that is version-tagged or the stable version - use a docker image that is version-tagged or the stable version
- use a package version that is from the stable or version stream - use a package version that is from the stable or version stream - if you are and it is not working, run `sudo inventree run invoke update` to re-run the upgrade
- install node and yarn on the server to build the frontend with the [invoke](../start/invoke.md) task `int.frontend-build` - install node and yarn on the server to build the frontend with the [invoke](../start/invoke.md) task `int.frontend-build`
Raise an issue if none of these options work. Raise an issue if none of these options work.

View File

@ -83,7 +83,7 @@ Date and time values are stored in the database in UTC format, and are converted
By default, the InvenTree server will not automatically apply database migrations. When the InvenTree installation is updated (*or a plugin is installed which requires database migrations*), database migrations must be applied manually by the system administrator. By default, the InvenTree server will not automatically apply database migrations. When the InvenTree installation is updated (*or a plugin is installed which requires database migrations*), database migrations must be applied manually by the system administrator.
With "auto update" enabled, the InvenTree server will automatically apply database migrations as required. To enable automatic database updates, set `INVENTREE_AUTO_UPDATE` to `True`. With "auto update" enabled, the InvenTree server will automatically apply database migrations as required when plugins are changed. To enable automatic database updates, set `INVENTREE_AUTO_UPDATE` to `True`. However, this setting is not sufficient when updating your InvenTree installation - you must still ensure that you follow the required steps for updating InvenTree as per your installation method.
## Debugging and Logging Options ## Debugging and Logging Options
@ -135,6 +135,7 @@ Depending on how your InvenTree installation is configured, you will need to pay
| INVENTREE_CORS_ORIGIN_WHITELIST | cors.whitelist | List of whitelisted CORS URLs. Refer to the [django-cors-headers documentation](https://github.com/adamchainz/django-cors-headers#cors_allowed_origins-sequencestr) | Uses the *INVENTREE_SITE_URL* parameter, if set. Otherwise, an empty list. | | INVENTREE_CORS_ORIGIN_WHITELIST | cors.whitelist | List of whitelisted CORS URLs. Refer to the [django-cors-headers documentation](https://github.com/adamchainz/django-cors-headers#cors_allowed_origins-sequencestr) | Uses the *INVENTREE_SITE_URL* parameter, if set. Otherwise, an empty list. |
| INVENTREE_CORS_ORIGIN_REGEX | cors.regex | List of regular expressions for CORS whitelisted URL patterns | *Empty list* | | INVENTREE_CORS_ORIGIN_REGEX | cors.regex | List of regular expressions for CORS whitelisted URL patterns | *Empty list* |
| INVENTREE_CORS_ALLOW_CREDENTIALS | cors.allow_credentials | Allow cookies in cross-site requests | `True` | | INVENTREE_CORS_ALLOW_CREDENTIALS | cors.allow_credentials | Allow cookies in cross-site requests | `True` |
| INVENTREE_SITE_LAX_PROTOCOL | site_lax_protocol | Ignore protocol mismatches on INVE-E7 site checks | `True` |
| INVENTREE_USE_X_FORWARDED_HOST | use_x_forwarded_host | Use forwarded host header | `False` | | INVENTREE_USE_X_FORWARDED_HOST | use_x_forwarded_host | Use forwarded host header | `False` |
| INVENTREE_USE_X_FORWARDED_PORT | use_x_forwarded_port | Use forwarded port header | `False` | | INVENTREE_USE_X_FORWARDED_PORT | use_x_forwarded_port | Use forwarded port header | `False` |
| INVENTREE_USE_X_FORWARDED_PROTO | use_x_forwarded_proto | Use forwarded protocol header | `False` | | INVENTREE_USE_X_FORWARDED_PROTO | use_x_forwarded_proto | Use forwarded protocol header | `False` |

View File

@ -183,6 +183,32 @@ docker compose run --rm inventree-server invoke export-records -f /home/inventre
This will export database records to the file `data.json` in your mounted volume directory. This will export database records to the file `data.json` in your mounted volume directory.
## Viewing Logs
To view the logs for the InvenTree container(s), use the following command:
```bash
docker compose logs
```
To view the logs for a specific container, use the following command:
```bash
docker compose logs <container-name>
```
e.g.
```bash
docker compose logs inventree-server
```
You can also "follow" the logs in real time, using the `-f` flag:
```bash
docker compose logs -f
```
## Further Configuration ## Further Configuration
### Check your security posture ### Check your security posture

View File

@ -37,7 +37,7 @@ The installer creates the following directories:
| `/opt/inventree/` | InvenTree application files | | `/opt/inventree/` | InvenTree application files |
| `/opt/inventree/data/` | InvenTree data files | | `/opt/inventree/data/` | InvenTree data files |
#### Performed steps #### Performed Steps
The installer script performs the following functions: The installer script performs the following functions:
@ -111,7 +111,7 @@ To stop the automatic generation of an admin user, generate an empty file needs
By default, InvenTree is served internally on port 6000 and then proxied via Nginx. The config is placed in `/etc/nginx/sites-enabled/inventree.conf` and overwritten on each update. The location can be set with the environment variable `SETUP_NGINX_FILE`. By default, InvenTree is served internally on port 6000 and then proxied via Nginx. The config is placed in `/etc/nginx/sites-enabled/inventree.conf` and overwritten on each update. The location can be set with the environment variable `SETUP_NGINX_FILE`.
This only serves an HTTP version of InvenTree, to use HTTPS (recommended for production) or customize any further an additional config file should be used. This only serves an HTTP version of InvenTree, to use HTTPS (recommended for production) or customize any further an additional config file should be used.
#### Extra python packages #### Extra Python Packages
Extra python packages can be installed by setting the environment variable `SETUP_EXTRA_PIP`. Extra python packages can be installed by setting the environment variable `SETUP_EXTRA_PIP`.
#### Database Options #### Database Options
@ -120,25 +120,51 @@ The used database backend can be configured with environment variables (before t
## Moving Data ## Moving Data
To change the data storage location, link the new location to `/opt/inventree/data`. To change the data storage location, link the new location to `/opt/inventree/data`. A rough outline of steps to achieve this could be:
A rough outline of steps to achieve this could be:
- shut down the app service(s) `inventree` and webserver `nginx` - Shut down the app service(s) `inventree` and webserver `nginx`
- copy data to the new location - Copy data to the new location
- check everything was transferred successfully - Check everything was transferred successfully
- delete the old location - Delete the old location
- create a symlink from the old location to the new one - Create a symlink from the old location to the new one
- start up the services again - Start up the services again
## Updating InvenTree ## Updating InvenTree
To update InvenTree run `apt install --only-upgrade inventree` - this might need to be run as a sudo user. To update InvenTree run the following command, which updates the InvenTree package to the latest version:
```bash
apt install --only-upgrade inventree
```
Note that this command may need to be run as a sudo user.
## Controlling InvenTree ## Controlling InvenTree
### Services ### Services
InvenTree installs multiple services that can be controlled with your local system runner (`service` or `systemctl`). InvenTree installs multiple services that can be controlled with your local system runner (`service` or `systemctl`):
The service `inventree` controls everything, `inventree-web` (the [InvenTree web server](./processes.md#web-server)) and `inventree-worker` the [background worker(s)](./processes.md#background-worker).
- `inventree` - The main InvenTree service that controls the web server and background worker(s)
- `inventree-web` - The InvenTree [web server](./processes.md#web-server) process(es)
- `inventree-worker` - The InvenTree [background worker(s)](./processes.md#background-worker) process(es)
#### Restarting Services
To restart the InvenTree services, use the following commands as necessary:
```bash
# Restart all InvenTree services
inventree restart
# Restart the web server only
inventree restart web
# Restart the worker only
inventree restart worker
```
### Scaling Workers
More instances of the worker can be instantiated from the command line. This is only meant for advanced users. More instances of the worker can be instantiated from the command line. This is only meant for advanced users.
@ -180,6 +206,26 @@ For example, to print InvenTree version information:
inventree run invoke version inventree run invoke version
``` ```
### Viewing Logs
To view the logs of the InvenTree services, use the following commands:
```bash
inventree logs
```
To view just the tail of the logs, use:
```bash
inventree logs --tail
```
Or, to follow the logs in real-time:
```bash
inventree logs --follow
```
## Architecture ## Architecture
The packages are provided by [packager.io](https://packager.io/). They are built each time updates are pushed to GitHub and released about 10 minutes later. The local package index must be updated to see the new release in the package manager. The packages are provided by [packager.io](https://packager.io/). They are built each time updates are pushed to GitHub and released about 10 minutes later. The local package index must be updated to see the new release in the package manager.

View File

@ -132,9 +132,10 @@ nav:
- Docker: - Docker:
- Introduction: start/docker.md - Introduction: start/docker.md
- Installation: start/docker_install.md - Installation: start/docker_install.md
- Installer:
- Installer: start/installer.md
- Bare Metal: - Bare Metal:
- Introduction: start/install.md - Introduction: start/install.md
- Installer: start/installer.md
- Production: start/bare_prod.md - Production: start/bare_prod.md
- Development: start/bare_dev.md - Development: start/bare_dev.md
- User Accounts: start/accounts.md - User Accounts: start/accounts.md

View File

@ -305,8 +305,8 @@ class InfoView(APIView):
'login_message': helpers.getCustomOption('login_message'), 'login_message': helpers.getCustomOption('login_message'),
'navbar_message': helpers.getCustomOption('navbar_message'), 'navbar_message': helpers.getCustomOption('navbar_message'),
}, },
'active_plugins': plugins_info(),
# Following fields are only available to staff users # Following fields are only available to staff users
'active_plugins': plugins_info() if is_staff else None,
'system_health': check_system_health() if is_staff else None, 'system_health': check_system_health() if is_staff else None,
'database': InvenTree.version.inventreeDatabase() if is_staff else None, 'database': InvenTree.version.inventreeDatabase() if is_staff else None,
'platform': InvenTree.version.inventreePlatform() if is_staff else None, 'platform': InvenTree.version.inventreePlatform() if is_staff else None,

View File

@ -1,11 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 390 INVENTREE_API_VERSION = 391
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v391 -> 2025-09-06 : https://github.com/inventree/InvenTree/pull/10279
- Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint
v390 -> 2025-09-03 : https://github.com/inventree/InvenTree/pull/10257 v390 -> 2025-09-03 : https://github.com/inventree/InvenTree/pull/10257
- Fixes limitation on adding virtual parts to a SalesOrder - Fixes limitation on adding virtual parts to a SalesOrder
- Additional query filter options for BomItem API endpoint - Additional query filter options for BomItem API endpoint

View File

@ -76,8 +76,9 @@ def get_root_dir() -> Path:
def inventreeInstaller() -> Optional[str]: def inventreeInstaller() -> Optional[str]:
"""Returns the installer for the running codebase - if set or detectable.""" """Returns the installer for the running codebase - if set or detectable."""
# First look in the environment variables, e.g. if running in docker load_version_file()
# First look in the environment variables, e.g. if running in docker
installer = os.environ.get('INVENTREE_PKG_INSTALLER', '') installer = os.environ.get('INVENTREE_PKG_INSTALLER', '')
if installer: if installer:
@ -121,6 +122,11 @@ def get_testfolder_dir() -> Path:
return get_base_dir().joinpath('_testfolder').resolve() return get_base_dir().joinpath('_testfolder').resolve()
def get_version_file() -> Path:
"""Returns the path of the InvenTree VERSION file. This does not ensure that the file exists."""
return get_root_dir().joinpath('VERSION').resolve()
def ensure_dir(path: Path, storage=None) -> None: def ensure_dir(path: Path, storage=None) -> None:
"""Ensure that a directory exists. """Ensure that a directory exists.
@ -592,3 +598,28 @@ def check_config_dir(
pass pass
return return
VERSION_LOADED = False
"""Flag to indicate if the VERSION file has been loaded in this process."""
def load_version_file():
"""Load the VERSION file if it exists and place the contents into the general execution environment.
Returns:
True if the VERSION file was loaded (now or previously), False otherwise.
"""
global VERSION_LOADED
if VERSION_LOADED:
return True
# Load the VERSION file if it exists
from dotenv import load_dotenv
version_file = get_version_file()
if version_file.exists():
load_dotenv(version_file)
VERSION_LOADED = True
return True
return False

View File

@ -15,6 +15,8 @@ from rest_framework.fields import URLField as RestURLField
from rest_framework.fields import empty from rest_framework.fields import empty
import InvenTree.helpers import InvenTree.helpers
import InvenTree.ready
from common.currency import currency_code_default
from common.settings import get_global_setting from common.settings import get_global_setting
from .validators import AllowedURLValidator, allowable_url_schemes from .validators import AllowedURLValidator, allowable_url_schemes
@ -59,7 +61,7 @@ class InvenTreeURLField(models.URLField):
def money_kwargs(**kwargs): def money_kwargs(**kwargs):
"""Returns the database settings for MoneyFields.""" """Returns the database settings for MoneyFields."""
from common.currency import currency_code_default, currency_code_mappings from common.currency import currency_code_mappings
# Default values (if not specified) # Default values (if not specified)
if 'max_digits' not in kwargs: if 'max_digits' not in kwargs:
@ -71,8 +73,14 @@ def money_kwargs(**kwargs):
if 'currency_choices' not in kwargs: if 'currency_choices' not in kwargs:
kwargs['currency_choices'] = currency_code_mappings() kwargs['currency_choices'] = currency_code_mappings()
if 'default_currency' not in kwargs: if InvenTree.ready.isRunningMigrations():
kwargs['default_currency'] = currency_code_default() # During migrations, avoid setting a default currency
# This prevents issues related to early evaluation of the default currency value
kwargs['default_currency'] = ''
else:
# Override default currency with a callable function
# This ensures that the default currency is always up-to-date
kwargs['default_currency'] = currency_code_default
return kwargs return kwargs

View File

@ -17,6 +17,7 @@ from error_report.middleware import ExceptionProcessor
from common.settings import get_global_setting from common.settings import get_global_setting
from InvenTree.AllUserRequire2FAMiddleware import AllUserRequire2FAMiddleware from InvenTree.AllUserRequire2FAMiddleware import AllUserRequire2FAMiddleware
from InvenTree.cache import create_session_cache, delete_session_cache from InvenTree.cache import create_session_cache, delete_session_cache
from InvenTree.config import CONFIG_LOOKUPS, inventreeInstaller
from users.models import ApiToken from users.models import ApiToken
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@ -234,27 +235,75 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
if path in urls or any(path.startswith(p) for p in paths_ignore): if path in urls or any(path.startswith(p) for p in paths_ignore):
return None return None
# Ensure that the settings are set correctly with the current request # treat the accessed scheme and host
accessed_scheme = request._current_scheme_host accessed_scheme = request._current_scheme_host
if accessed_scheme and not accessed_scheme.startswith(settings.SITE_URL): referer = urlsplit(accessed_scheme)
msg = f'INVE-E7: The used path `{accessed_scheme}` does not match the SITE_URL `{settings.SITE_URL}`'
logger.error(msg) site_url = urlsplit(settings.SITE_URL)
return render(
request, 'config_error.html', {'error_message': msg}, status=500 # Check if the accessed URL matches the SITE_URL setting
site_url_match = (
(
# Exact match on domain
is_same_domain(referer.netloc, site_url.netloc)
and referer.scheme == site_url.scheme
) )
or (
# Lax protocol match, accessed URL starts with SITE_URL
settings.SITE_LAX_PROTOCOL_CHECK
and accessed_scheme.startswith(settings.SITE_URL)
)
or (
# Lax protocol match, same domain
settings.SITE_LAX_PROTOCOL_CHECK
and referer.hostname == site_url.hostname
)
)
if not site_url_match:
# The accessed URL does not match the SITE_URL setting
if (
isinstance(settings.CSRF_TRUSTED_ORIGINS, list)
and len(settings.CSRF_TRUSTED_ORIGINS) > 1
):
# The used url might not be the primary url - next check determines if in a trusted origins
pass
else:
source = CONFIG_LOOKUPS.get('INVENTREE_SITE_URL', {}).get(
'source', 'unknown'
)
dpl_method = inventreeInstaller()
msg = f'INVE-E7: The visited path `{accessed_scheme}` does not match the SITE_URL `{settings.SITE_URL}`. The INVENTREE_SITE_URL is set via `{source}` config method - deployment method `{dpl_method}`'
logger.error(msg)
return render(
request, 'config_error.html', {'error_message': msg}, status=500
)
trusted_origins_match = (
# Matching domain found in allowed origins
any(
is_same_domain(referer.netloc, host)
for host in [
urlsplit(origin).netloc.lstrip('*')
for origin in settings.CSRF_TRUSTED_ORIGINS
]
)
) or (
# Lax protocol match allowed
settings.SITE_LAX_PROTOCOL_CHECK
and any(
referer.hostname == urlsplit(origin).hostname
for origin in settings.CSRF_TRUSTED_ORIGINS
)
)
# Check trusted origins # Check trusted origins
referer = urlsplit(accessed_scheme) if not trusted_origins_match:
if not any(
is_same_domain(referer.netloc, host)
for host in [
urlsplit(origin).netloc.lstrip('*')
for origin in settings.CSRF_TRUSTED_ORIGINS
]
):
msg = f'INVE-E7: The used path `{accessed_scheme}` is not in the TRUSTED_ORIGINS' msg = f'INVE-E7: The used path `{accessed_scheme}` is not in the TRUSTED_ORIGINS'
logger.error(msg) logger.error(msg)
return render( return render(
request, 'config_error.html', {'error_message': msg}, status=500 request, 'config_error.html', {'error_message': msg}, status=500
) )
# All checks passed
return None return None

View File

@ -23,7 +23,6 @@ from django.http import Http404, HttpResponseGone
import structlog import structlog
from corsheaders.defaults import default_headers as default_cors_headers from corsheaders.defaults import default_headers as default_cors_headers
from dotenv import load_dotenv
from InvenTree.cache import get_cache_config, is_global_cache_enabled from InvenTree.cache import get_cache_config, is_global_cache_enabled
from InvenTree.config import ( from InvenTree.config import (
@ -73,11 +72,7 @@ BASE_DIR = config.get_base_dir()
# Load configuration data # Load configuration data
CONFIG = config.load_config_data(set_cache=True) CONFIG = config.load_config_data(set_cache=True)
config.load_version_file()
# Load VERSION data if it exists
version_file = config.get_root_dir().joinpath('VERSION')
if version_file.exists():
load_dotenv(version_file)
# Default action is to run the system in Debug mode # Default action is to run the system in Debug mode
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
@ -1052,6 +1047,9 @@ DATE_INPUT_FORMATS = ['%Y-%m-%d']
# Site URL can be specified statically, or via a run-time setting # Site URL can be specified statically, or via a run-time setting
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None) SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
SITE_LAX_PROTOCOL_CHECK = get_boolean_setting(
'INVENTREE_SITE_LAX_PROTOCOL', 'site_lax_protocol', True
)
if SITE_URL: if SITE_URL:
SITE_URL = str(SITE_URL).strip().rstrip('/') SITE_URL = str(SITE_URL).strip().rstrip('/')

View File

@ -668,6 +668,11 @@ def check_for_migrations(force: bool = False, reload_registry: bool = True) -> b
Returns bool indicating if migrations are up to date Returns bool indicating if migrations are up to date
""" """
from . import ready
if ready.isRunningMigrations() or ready.isRunningBackup():
# Migrations are already running!
return False
def set_pending_migrations(n: int): def set_pending_migrations(n: int):
"""Helper function to inform the user about pending migrations.""" """Helper function to inform the user about pending migrations."""
@ -718,6 +723,8 @@ def check_for_migrations(force: bool = False, reload_registry: bool = True) -> b
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3': if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
raise e raise e
logger.exception('Error during migrations: %s', e) logger.exception('Error during migrations: %s', e)
except Exception as e: # pragma: no cover
logger.exception('Error during migrations: %s', e)
else: else:
set_pending_migrations(0) set_pending_migrations(0)

View File

@ -605,9 +605,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
response = self.get(url, max_query_count=20) response = self.get(url, max_query_count=20)
data = response.json() data = response.json()
self.assertEqual(data['database'], None) self.assertEqual(data['database'], None)
self.assertIsNotNone(data.get('active_plugins'))
# No active plugin info for anon user
self.assertIsNone(data.get('active_plugins'))
# Staff # Staff
response = self.get( response = self.get(

View File

@ -1,5 +1,6 @@
"""Tests for middleware functions.""" """Tests for middleware functions."""
from django.conf import settings
from django.http import Http404 from django.http import Http404
from django.urls import reverse from django.urls import reverse
@ -87,36 +88,117 @@ class MiddlewareTests(InvenTreeTestCase):
log_error('testpath') log_error('testpath')
check(1) check(1)
def do_positive_test(self, response):
"""Helper function to check for positive test results."""
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'INVE-E7')
self.assertContains(response, 'window.INVENTREE_SETTINGS')
def test_site_url_checks(self): def test_site_url_checks(self):
"""Test that the site URL check is correctly working.""" """Test that the site URL check is correctly working."""
# correctly set # simple setup
with self.settings( with self.settings(
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver'] SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver']
): ):
response = self.client.get(reverse('web')) response = self.client.get(reverse('web'))
self.assertEqual(response.status_code, 200) self.do_positive_test(response)
self.assertNotContains(response, 'INVE-E7')
self.assertContains(response, 'window.INVENTREE_SETTINGS')
# wrongly set site URL # simple setup with wildcard
with self.settings(SITE_URL='https://example.com'): with self.settings(
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://*.testserver']
):
response = self.client.get(reverse('web')) response = self.client.get(reverse('web'))
self.assertEqual(response.status_code, 500) self.do_positive_test(response)
self.assertContains(
response,
'INVE-E7: The used path `http://testserver` does not match',
status_code=500,
)
self.assertNotContains(
response, 'window.INVENTREE_SETTINGS', status_code=500
)
def test_site_lax_protocol(self):
"""Test that the site URL check is correctly working with/without lax protocol check."""
# Test that a completely different host fails
with self.settings(
SITE_URL='https://testserver', CSRF_TRUSTED_ORIGINS=['https://testserver']
):
response = self.client.get(
reverse('web'), HTTP_HOST='otherhost.example.com'
)
self.assertContains(response, 'INVE-E7: The visited path', status_code=500)
# Simple setup with proxy
with self.settings(
SITE_URL='https://testserver', CSRF_TRUSTED_ORIGINS=['https://testserver']
):
response = self.client.get(reverse('web'))
self.do_positive_test(response)
# No worky if strong security
with self.settings(
SITE_URL='https://testserver',
CSRF_TRUSTED_ORIGINS=['https://testserver'],
SITE_LAX_PROTOCOL_CHECK=False,
):
response = self.client.get(reverse('web'))
self.assertContains(response, 'INVE-E7: The visited path', status_code=500)
def test_site_url_port(self):
"""URL checks with different ports."""
with self.settings(
SITE_URL='https://testserver:8000',
CSRF_TRUSTED_ORIGINS=['https://testserver:8000'],
):
response = self.client.get(reverse('web'), HTTP_HOST='testserver:8008')
self.do_positive_test(response)
# Try again with strict protocol check
with self.settings(
SITE_URL='https://testserver:8000',
CSRF_TRUSTED_ORIGINS=['https://testserver:8000'],
SITE_LAX_PROTOCOL_CHECK=False,
):
response = self.client.get(reverse('web'), HTTP_HOST='testserver:8008')
self.assertContains(response, 'INVE-E7: The visited path', status_code=500)
def test_site_url_checks_multi(self):
"""Test that the site URL check is correctly working in a multi-site setup."""
# multi-site setup with trusted origins
with self.settings(
SITE_URL='https://testserver.example.com',
CSRF_TRUSTED_ORIGINS=[
'http://testserver',
'https://testserver.example.com',
],
):
# this will run with testserver as host by default
response = self.client.get(reverse('web'))
self.do_positive_test(response)
# Now test with the "outside" url name
response = self.client.get(
'https://testserver.example.com/web/',
SERVER_NAME='testserver.example.com',
)
self.do_positive_test(response)
# A non-trusted origin must still fail in multi - origin setup
response = self.client.get(
'https://not-my-testserver.example.com/web/',
SERVER_NAME='not-my-testserver.example.com',
)
self.assertEqual(response.status_code, 500)
# Even if it is a subdomain
response = self.client.get(
'https://not-my.testserver.example.com/web/',
SERVER_NAME='not-my.testserver.example.com',
)
self.assertEqual(response.status_code, 500)
def test_site_url_checks_fails(self):
"""Test that the site URL check is correctly failing.
Important for security.
"""
# wrongly set but in debug -> is ignored # wrongly set but in debug -> is ignored
with self.settings(SITE_URL='https://example.com', DEBUG=True): with self.settings(SITE_URL='https://example.com', DEBUG=True):
response = self.client.get(reverse('web')) response = self.client.get(reverse('web'))
self.assertEqual(response.status_code, 200) self.do_positive_test(response)
self.assertNotContains(response, 'INVE-E7')
self.assertContains(response, 'window.INVENTREE_SETTINGS')
# wrongly set cors # wrongly set cors
with self.settings( with self.settings(
@ -125,7 +207,6 @@ class MiddlewareTests(InvenTreeTestCase):
CSRF_TRUSTED_ORIGINS=['https://example.com'], CSRF_TRUSTED_ORIGINS=['https://example.com'],
): ):
response = self.client.get(reverse('web')) response = self.client.get(reverse('web'))
self.assertEqual(response.status_code, 500)
self.assertContains( self.assertContains(
response, 'is not in the TRUSTED_ORIGINS', status_code=500 response, 'is not in the TRUSTED_ORIGINS', status_code=500
) )
@ -133,10 +214,33 @@ class MiddlewareTests(InvenTreeTestCase):
response, 'window.INVENTREE_SETTINGS', status_code=500 response, 'window.INVENTREE_SETTINGS', status_code=500
) )
# wrongly set site URL
with self.settings( with self.settings(
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://*.testserver'] SITE_URL='https://example.com',
CSRF_TRUSTED_ORIGINS=['http://localhost:8000'],
): ):
response = self.client.get(reverse('web')) response = self.client.get(reverse('web'))
self.assertEqual(response.status_code, 200) self.assertContains(
self.assertNotContains(response, 'INVE-E7') response,
self.assertContains(response, 'window.INVENTREE_SETTINGS') 'INVE-E7: The visited path `http://testserver` ',
status_code=500,
)
self.assertNotContains(
response, 'window.INVENTREE_SETTINGS', status_code=500
)
# Log stuff # TODO remove
print(
'###DBG-TST###',
'site',
settings.SITE_URL,
'trusted',
settings.CSRF_TRUSTED_ORIGINS,
)
# Check that the correct step triggers the error message
self.assertContains(
response,
'INVE-E7: The visited path `http://testserver` does not match',
status_code=500,
)

View File

@ -18,7 +18,7 @@ from django.conf import settings
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version # InvenTree software version
INVENTREE_SW_VERSION = '0.18.0 dev' INVENTREE_SW_VERSION = '1.0.8'
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -269,7 +269,7 @@ def inventreeBranch():
branch = os.environ.get('INVENTREE_PKG_BRANCH', '') branch = os.environ.get('INVENTREE_PKG_BRANCH', '')
if branch: if branch:
return branch return ' '.join(branch.splitlines())
if main_branch is None: if main_branch is None:
return None return None

View File

@ -480,8 +480,8 @@ class BuildLineFilter(rest_filters.FilterSet):
def filter_allocated(self, queryset, name, value): def filter_allocated(self, queryset, name, value):
"""Filter by whether each BuildLine is fully allocated.""" """Filter by whether each BuildLine is fully allocated."""
if str2bool(value): if str2bool(value):
return queryset.filter(allocated__gte=F('quantity')) return queryset.filter(allocated__gte=F('quantity') - F('consumed'))
return queryset.filter(allocated__lt=F('quantity')) return queryset.filter(allocated__lt=F('quantity') - F('consumed'))
consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed') consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed')

View File

@ -1,21 +1,22 @@
"""Queryset filtering helper functions for the Build app.""" """Queryset filtering helper functions for the Build app."""
from django.db import models from django.db.models import DecimalField, ExpressionWrapper, F, Max, Sum
from django.db.models import Q, Sum from django.db.models.functions import Coalesce, Greatest
from django.db.models.functions import Coalesce
def annotate_allocated_quantity(queryset: Q) -> Q: def annotate_required_quantity():
"""Annotate the 'allocated' quantity for each build item in the queryset. """Annotate the 'required' quantity for each build item in the queryset."""
# Note: The use of Max() here is intentional, to avoid aggregation issues in MySQL
Arguments: # Ref: https://github.com/inventree/InvenTree/pull/10398
queryset: The BuildLine queryset to annotate return Greatest(
ExpressionWrapper(
""" Max(F('quantity')) - Max(F('consumed')), output_field=DecimalField()
queryset = queryset.prefetch_related('allocations') ),
0,
return queryset.annotate( output_field=DecimalField(),
allocated=Coalesce(
Sum('allocations__quantity'), 0, output_field=models.DecimalField()
)
) )
def annotate_allocated_quantity():
"""Annotate the 'allocated' quantity for each build item in the queryset."""
return Coalesce(Sum('allocations__quantity'), 0, output_field=DecimalField())

View File

@ -29,7 +29,7 @@ import report.mixins
import stock.models import stock.models
import users.models import users.models
from build.events import BuildEvents from build.events import BuildEvents
from build.filters import annotate_allocated_quantity from build.filters import annotate_allocated_quantity, annotate_required_quantity
from build.status_codes import BuildStatus, BuildStatusGroups from build.status_codes import BuildStatus, BuildStatusGroups
from build.validators import ( from build.validators import (
generate_next_build_reference, generate_next_build_reference,
@ -1062,7 +1062,7 @@ class Build(
lines = self.untracked_line_items.all() lines = self.untracked_line_items.all()
lines = lines.exclude(bom_item__consumable=True) lines = lines.exclude(bom_item__consumable=True)
lines = annotate_allocated_quantity(lines) lines = lines.annotate(allocated=annotate_allocated_quantity())
for build_line in lines: for build_line in lines:
reduce_by = build_line.allocated - build_line.quantity reduce_by = build_line.allocated - build_line.quantity
@ -1381,10 +1381,12 @@ class Build(
elif tracked is False: elif tracked is False:
lines = lines.filter(bom_item__sub_part__trackable=False) lines = lines.filter(bom_item__sub_part__trackable=False)
lines = annotate_allocated_quantity(lines) lines = lines.prefetch_related('allocations')
# Filter out any lines which have been fully allocated lines = lines.annotate(
lines = lines.filter(allocated__lt=F('quantity')) allocated=annotate_allocated_quantity(),
required=annotate_required_quantity(),
).filter(allocated__lt=F('required'))
return lines return lines
@ -1436,10 +1438,14 @@ class Build(
True if any BuildLine has been over-allocated. True if any BuildLine has been over-allocated.
""" """
lines = self.build_lines.all().exclude(bom_item__consumable=True) lines = self.build_lines.all().exclude(bom_item__consumable=True)
lines = annotate_allocated_quantity(lines)
lines = lines.prefetch_related('allocations')
# Find any lines which have been over-allocated # Find any lines which have been over-allocated
lines = lines.filter(allocated__gt=F('quantity')) lines = lines.annotate(
allocated=annotate_allocated_quantity(),
required=annotate_required_quantity(),
).filter(allocated__gt=F('required'))
return lines.count() > 0 return lines.count() > 0
@ -1644,19 +1650,30 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
return allocated['q'] return allocated['q']
def unallocated_quantity(self): def unallocated_quantity(self):
"""Return the unallocated quantity for this BuildLine.""" """Return the unallocated quantity for this BuildLine.
return max(self.quantity - self.allocated_quantity(), 0)
- Start with the required quantity
- Subtract the consumed quantity
- Subtract the allocated quantity
Return the remaining quantity (or zero if negative)
"""
return max(self.quantity - self.consumed - self.allocated_quantity(), 0)
def is_fully_allocated(self): def is_fully_allocated(self):
"""Return True if this BuildLine is fully allocated.""" """Return True if this BuildLine is fully allocated."""
if self.bom_item.consumable: if self.bom_item.consumable:
return True return True
return self.allocated_quantity() >= self.quantity required = max(0, self.quantity - self.consumed)
return self.allocated_quantity() >= required
def is_overallocated(self): def is_overallocated(self):
"""Return True if this BuildLine is over-allocated.""" """Return True if this BuildLine is over-allocated."""
return self.allocated_quantity() > self.quantity required = max(0, self.quantity - self.consumed)
return self.allocated_quantity() > required
def is_fully_consumed(self): def is_fully_consumed(self):
"""Return True if this BuildLine is fully consumed.""" """Return True if this BuildLine is fully consumed."""

View File

@ -853,6 +853,78 @@ class AutoAllocationTests(BuildTestBase):
self.assertEqual(self.line_1.unallocated_quantity(), 0) self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 0) self.assertEqual(self.line_2.unallocated_quantity(), 0)
def test_allocate_consumed(self):
"""Test for auto-allocation against a build which has been fully consumed.
Steps:
1. Fully allocate the build (using the auto-allocate function)
2. Consume allocated stock
3. Ensure that all allocations are removed
4. Re-run the auto-allocate function
5. Check that no new allocations have been made
"""
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.build.is_fully_allocated(tracked=False))
# Auto allocate stock against the build order
self.build.auto_allocate_stock(
interchangeable=True, substitutes=True, optional_items=True
)
self.assertEqual(self.line_1.allocated_quantity(), 50)
self.assertEqual(self.line_2.allocated_quantity(), 30)
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 0)
self.assertTrue(self.line_1.is_fully_allocated())
self.assertTrue(self.line_2.is_fully_allocated())
self.assertFalse(self.line_1.is_overallocated())
self.assertFalse(self.line_2.is_overallocated())
N = self.build.allocated_stock.count()
self.assertEqual(self.line_1.allocations.count(), 2)
self.assertEqual(self.line_2.allocations.count(), 6)
for item in self.line_1.allocations.all():
item.complete_allocation()
for item in self.line_2.allocations.all():
item.complete_allocation()
self.line_1.refresh_from_db()
self.line_2.refresh_from_db()
self.assertTrue(self.line_1.is_fully_allocated())
self.assertTrue(self.line_2.is_fully_allocated())
self.assertFalse(self.line_1.is_overallocated())
self.assertFalse(self.line_2.is_overallocated())
self.assertEqual(self.line_1.allocations.count(), 0)
self.assertEqual(self.line_2.allocations.count(), 0)
self.assertEqual(self.line_1.quantity, self.line_1.consumed)
self.assertEqual(self.line_2.quantity, self.line_2.consumed)
# Check that the "allocations" have been removed
self.assertEqual(self.build.allocated_stock.count(), N - 8)
# Now, try to auto-allocate again
self.build.auto_allocate_stock(
interchangeable=True, substitutes=True, optional_items=True
)
# Ensure that there are no "new" allocations (there should be none!)
self.assertEqual(self.line_1.allocated_quantity(), 0)
self.assertEqual(self.line_2.allocated_quantity(), 0)
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 0)
self.assertEqual(self.build.allocated_stock.count(), N - 8)
class ExternalBuildTest(InvenTreeAPITestCase): class ExternalBuildTest(InvenTreeAPITestCase):
"""Unit tests for external build order functionality.""" """Unit tests for external build order functionality."""

View File

@ -11,6 +11,7 @@ import structlog
from moneyed import CURRENCIES from moneyed import CURRENCIES
import InvenTree.helpers import InvenTree.helpers
import InvenTree.ready
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')

View File

@ -3,7 +3,6 @@
from django.db import migrations, connection from django.db import migrations, connection
import djmoney.models.fields import djmoney.models.fields
import common.currency import common.currency
import common.settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -17,11 +16,11 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='supplierpricebreak', model_name='supplierpricebreak',
name='price', name='price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'), field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
), ),
migrations.AddField( migrations.AddField(
model_name='supplierpricebreak', model_name='supplierpricebreak',
name='price_currency', name='price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3), field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default='', editable=False, max_length=3),
), ),
] ]

View File

@ -2,7 +2,6 @@
import InvenTree.validators import InvenTree.validators
import common.currency import common.currency
import common.settings
from django.db import migrations, models from django.db import migrations, models

View File

@ -110,7 +110,7 @@ sentry_enabled: False
#sentry_dsn: https://custom@custom.ingest.sentry.io/custom #sentry_dsn: https://custom@custom.ingest.sentry.io/custom
# OpenTelemetry tracing/metrics - disabled by default - refer to the documentation for full list of options # OpenTelemetry tracing/metrics - disabled by default - refer to the documentation for full list of options
# This can be used to send tracing data, logs and metrics to OpenTelemtry compatible backends # This can be used to send tracing data, logs and metrics to OpenTelemetry compatible backends
tracing: tracing:
enabled: false enabled: false
@ -142,9 +142,9 @@ allowed_hosts:
# use_x_forwarded_proto: true # use_x_forwarded_proto: true
# Cookie settings (nominally the default settings should be fine) # Cookie settings (nominally the default settings should be fine)
cookie: # cookie:
secure: false # secure: false
samesite: false # samesite: false
# Cross Origin Resource Sharing (CORS) settings (see https://github.com/adamchainz/django-cors-headers) # Cross Origin Resource Sharing (CORS) settings (see https://github.com/adamchainz/django-cors-headers)
cors: cors:
@ -203,7 +203,7 @@ remote_login_header: HTTP_REMOTE_USER
# - 'allauth.socialaccount.providers.github' # - 'allauth.socialaccount.providers.github'
# Add specific settings for social account providers (if required) # Add specific settings for social account providers (if required)
# Refer to the djngo-allauth documentation for more details: # Refer to the django-allauth documentation for more details:
# https://docs.allauth.org/en/latest/socialaccount/provider_configuration.html # https://docs.allauth.org/en/latest/socialaccount/provider_configuration.html
# social_providers: # social_providers:
# github: # github:

View File

@ -630,6 +630,38 @@ class DataImportRow(models.Model):
if value is None and field in default_values: if value is None and field in default_values:
value = default_values[field] value = default_values[field]
# If the field provides a set of valid 'choices', use that as a lookup
if field_type == 'choice' and 'choices' in field_def:
choices = field_def.get('choices', None)
if callable(choices):
choices = choices()
# Try to match the provided value against the available choices
choice_value = None
for choice in choices:
primary_value = choice['value']
display_value = choice['display_name']
if primary_value == value:
choice_value = primary_value
# Break on first match against a primary choice value
break
if display_value == value:
choice_value = primary_value
elif (
str(display_value).lower().strip() == str(value).lower().strip()
and choice_value is None
):
# Case-insensitive match against display value
choice_value = primary_value
if choice_value is not None:
value = choice_value
data[field] = value data[field] = value
self.data = data self.data = data
@ -693,9 +725,20 @@ class DataImportRow(models.Model):
try: try:
instance = self.session.model_class.objects.get(pk=instance_id) instance = self.session.model_class.objects.get(pk=instance_id)
except self.session.model_class.DoesNotExist: except self.session.model_class.DoesNotExist:
raise DjangoValidationError(_('No record found with the provided ID.')) self.errors = {
'non_field_errors': _('No record found with the provided ID')
+ f': {instance_id}'
}
return False
except ValueError: except ValueError:
raise DjangoValidationError(_('Invalid ID format provided.')) self.errors = {
'non_field_errors': _('Invalid ID format provided')
+ f': {instance_id}'
}
return False
except Exception as e:
self.errors = {'non_field_errors': str(e)}
return False
serializer = self.construct_serializer(instance=instance, request=request) serializer = self.construct_serializer(instance=instance, request=request)

View File

@ -3,7 +3,6 @@
from django.db import migrations from django.db import migrations
import djmoney.models.fields import djmoney.models.fields
import common.currency import common.currency
import common.settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -17,11 +16,11 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='purchaseorderlineitem', model_name='purchaseorderlineitem',
name='purchase_price', name='purchase_price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'), field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency='', help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
), ),
migrations.AddField( migrations.AddField(
model_name='purchaseorderlineitem', model_name='purchaseorderlineitem',
name='purchase_price_currency', name='purchase_price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3), field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default='', editable=False, max_length=3),
), ),
] ]

View File

@ -2,8 +2,6 @@
from django.db import migrations from django.db import migrations
import djmoney.models.fields import djmoney.models.fields
import common.currency
import common.settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -16,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='purchaseorderlineitem', model_name='purchaseorderlineitem',
name='purchase_price', name='purchase_price',
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'), field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency='', help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
), ),
] ]

View File

@ -2,7 +2,6 @@
from django.db import migrations from django.db import migrations
import common.currency import common.currency
import common.settings
import djmoney.models.fields import djmoney.models.fields
@ -16,11 +15,11 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='salesorderlineitem', model_name='salesorderlineitem',
name='sale_price', name='sale_price',
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit sale price', max_digits=19, null=True, verbose_name='Sale Price'), field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency='', help_text='Unit sale price', max_digits=19, null=True, verbose_name='Sale Price'),
), ),
migrations.AddField( migrations.AddField(
model_name='salesorderlineitem', model_name='salesorderlineitem',
name='sale_price_currency', name='sale_price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3), field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default='', editable=False, max_length=3),
), ),
] ]

View File

@ -16,11 +16,11 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='partsellpricebreak', model_name='partsellpricebreak',
name='price', name='price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'), field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
), ),
migrations.AddField( migrations.AddField(
model_name='partsellpricebreak', model_name='partsellpricebreak',
name='price_currency', name='price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3), field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default='', editable=False, max_length=3),
), ),
] ]

View File

@ -3,7 +3,6 @@
import InvenTree.fields import InvenTree.fields
import django.core.validators import django.core.validators
import common.currency import common.currency
import common.settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import djmoney.models.fields import djmoney.models.fields
@ -21,8 +20,8 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Price break quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Quantity')), ('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Price break quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Quantity')),
('price_currency', djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3)), ('price_currency', djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default='', editable=False, max_length=3)),
('price', djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price')), ('price', djmoney.models.fields.MoneyField(decimal_places=4, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price')),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='internalpricebreaks', to='part.part', verbose_name='Part')), ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='internalpricebreaks', to='part.part', verbose_name='Part')),
], ],
options={ options={

View File

@ -8,7 +8,7 @@ import djmoney.models.validators
import InvenTree.fields import InvenTree.fields
import common.currency import common.currency
import common.settings
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -4271,6 +4271,11 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
for sub in self.substitutes.all(): for sub in self.substitutes.all():
parts.add(sub.part) parts.add(sub.part)
# Account for variants of the substitute part (if allowed)
if allow_variants and self.allow_variants:
for sub_variant in sub.part.get_descendants(include_self=False):
parts.add(sub_variant)
valid_parts = [] valid_parts = []
for p in parts: for p in parts:

View File

@ -174,8 +174,8 @@ class StaleStockNotificationTests(InvenTreeTestCase):
def test_check_stale_stock_no_stale_items(self): def test_check_stale_stock_no_stale_items(self):
"""Test check_stale_stock when no stale items exist.""" """Test check_stale_stock when no stale items exist."""
# Clear all existing stock items # Clear all existing stock items
stock.models.StockItem.objects.update(parent=None)
stock.models.StockItem.objects.all().delete() stock.models.StockItem.objects.all().delete()
# Create only future expiry items # Create only future expiry items
today = helpers.current_date() today = helpers.current_date()
stock.models.StockItem.objects.create( stock.models.StockItem.objects.create(
@ -194,6 +194,7 @@ class StaleStockNotificationTests(InvenTreeTestCase):
def test_check_stale_stock_with_stale_items(self, mock_offload): def test_check_stale_stock_with_stale_items(self, mock_offload):
"""Test check_stale_stock when stale items exist.""" """Test check_stale_stock when stale items exist."""
# Clear existing stock items # Clear existing stock items
stock.models.StockItem.objects.update(parent=None)
stock.models.StockItem.objects.all().delete() stock.models.StockItem.objects.all().delete()
self.create_stock_items_with_expiry() self.create_stock_items_with_expiry()
@ -229,6 +230,7 @@ class StaleStockNotificationTests(InvenTreeTestCase):
def test_check_stale_stock_filtering(self): def test_check_stale_stock_filtering(self):
"""Test that check_stale_stock properly filters stock items.""" """Test that check_stale_stock properly filters stock items."""
# Clear all existing stock items first # Clear all existing stock items first
stock.models.StockItem.objects.update(parent=None)
stock.models.StockItem.objects.all().delete() stock.models.StockItem.objects.all().delete()
today = helpers.current_date() today = helpers.current_date()

View File

@ -111,7 +111,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
# Request custom panel information for a part instance # Request custom panel information for a part instance
response = self.get(url, data=query_data) response = self.get(url, data=query_data)
# There should be 4 active panels for the part by default # There should be 3 active panels for the part by default
self.assertEqual(3, len(response.data)) self.assertEqual(3, len(response.data))
_part.active = False _part.active = False
@ -119,8 +119,8 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
response = self.get(url, data=query_data) response = self.get(url, data=query_data)
# As the part is not active, only 3 panels left # As the part is not active, only 2 panels left
self.assertEqual(3, len(response.data)) self.assertEqual(2, len(response.data))
# Disable the "ENABLE_PART_PANELS" setting, and try again # Disable the "ENABLE_PART_PANELS" setting, and try again
plugin.set_setting('ENABLE_PART_PANELS', False) plugin.set_setting('ENABLE_PART_PANELS', False)

View File

@ -38,6 +38,10 @@ class InvenTreeUINotifications(NotificationMixin, InvenTreePlugin):
if not users: if not users:
return False return False
# Ensure that there is always target object - see https://github.com/inventree/InvenTree/issues/10435
if not target:
target = self.plugin_config()
# Bulk create notification messages for all provided users # Bulk create notification messages for all provided users
for user in users: for user in users:
entries.append( entries.append(

View File

@ -93,13 +93,17 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
except (Part.DoesNotExist, ValueError): except (Part.DoesNotExist, ValueError):
part = None part = None
panels.append({ # Only display this panel for "active" parts
'key': 'part-panel', if part and part.active:
'title': _('Part Panel'), panels.append({
'source': self.plugin_static_file('sample_panel.js:renderPartPanel'), 'key': 'part-panel',
'icon': 'ti:package_outline', 'title': _('Part Panel'),
'context': {'part_name': part.name if part else ''}, 'source': self.plugin_static_file(
}) 'sample_panel.js:renderPartPanel'
),
'icon': 'ti:package:outline',
'context': {'part_name': part.name if part else ''},
})
# Next, add a custom panel which will appear on the 'purchaseorder' page # Next, add a custom panel which will appear on the 'purchaseorder' page
if target_model == 'purchaseorder' and self.get_setting( if target_model == 'purchaseorder' and self.get_setting(

View File

@ -10,4 +10,4 @@ class VersionPlugin(InvenTreePlugin):
NAME = 'Sample Version Plugin' NAME = 'Sample Version Plugin'
DESCRIPTION = 'A simple plugin which shows how to use the version limits' DESCRIPTION = 'A simple plugin which shows how to use the version limits'
MIN_VERSION = '0.1.0' MIN_VERSION = '0.1.0'
MAX_VERSION = '1.0.0' MAX_VERSION = '2.0.0'

View File

@ -4,7 +4,7 @@ import logging
import os import os
from django.apps import AppConfig from django.apps import AppConfig
from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import AppRegistryNotReady, ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.db.utils import IntegrityError, OperationalError, ProgrammingError from django.db.utils import IntegrityError, OperationalError, ProgrammingError
@ -52,6 +52,8 @@ class ReportConfig(AppConfig):
try: try:
self.create_default_labels() self.create_default_labels()
self.create_default_reports() self.create_default_reports()
except ValidationError:
logger.warning('Validation error when creating default templates')
except ( except (
AppRegistryNotReady, AppRegistryNotReady,
IntegrityError, IntegrityError,
@ -162,6 +164,10 @@ class ReportConfig(AppConfig):
**template, template=self.file_from_template('label', filename) **template, template=self.file_from_template('label', filename)
) )
logger.info("Creating new label template: '%s'", template['name']) logger.info("Creating new label template: '%s'", template['name'])
except ValidationError:
logger.warning(
"Could not create label template: '%s'", template['name']
)
except Exception: except Exception:
InvenTree.exceptions.log_error('create_default_labels', scope='init') InvenTree.exceptions.log_error('create_default_labels', scope='init')
@ -261,5 +267,9 @@ class ReportConfig(AppConfig):
**template, template=self.file_from_template('report', filename) **template, template=self.file_from_template('report', filename)
) )
logger.info("Created new report template: '%s'", template['name']) logger.info("Created new report template: '%s'", template['name'])
except ValidationError:
logger.warning(
"Could not create report template: '%s'", template['name']
)
except Exception: except Exception:
InvenTree.exceptions.log_error('create_default_reports', scope='init') InvenTree.exceptions.log_error('create_default_reports', scope='init')

View File

@ -39,8 +39,9 @@ from InvenTree.filters import (
SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS, SEARCH_ORDER_FILTER_ALIAS,
InvenTreeDateFilter, InvenTreeDateFilter,
NumberOrNullFilter,
) )
from InvenTree.helpers import extract_serial_numbers, generateTestKey, isNull, str2bool from InvenTree.helpers import extract_serial_numbers, generateTestKey, str2bool
from InvenTree.mixins import ( from InvenTree.mixins import (
CreateAPI, CreateAPI,
CustomRetrieveUpdateDestroyAPI, CustomRetrieveUpdateDestroyAPI,
@ -933,6 +934,66 @@ class StockFilter(rest_filters.FilterSet):
else: else:
return queryset.exclude(stale_filter) return queryset.exclude(stale_filter)
exclude_tree = rest_filters.NumberFilter(
method='filter_exclude_tree',
label=_('Exclude Tree'),
help_text=_(
'Provide a StockItem PK to exclude that item and all its descendants'
),
)
def filter_exclude_tree(self, queryset, name, value):
"""Exclude a StockItem and all of its descendants from the queryset."""
try:
root = StockItem.objects.get(pk=value)
pks_to_exclude = [
item.pk for item in root.get_descendants(include_self=True)
]
return queryset.exclude(pk__in=pks_to_exclude)
except (ValueError, StockItem.DoesNotExist):
# If the value is invalid or the object doesn't exist, do nothing.
return queryset
cascade = rest_filters.BooleanFilter(
method='filter_cascade',
label=_('Cascade Locations'),
help_text=_('If true, include items in child locations of the given location'),
)
location = NumberOrNullFilter(
method='filter_location',
label=_('Location'),
help_text=_("Filter by numeric Location ID or the literal 'null'"),
)
def filter_cascade(self, queryset, name, value):
"""Dummy filter method for 'cascade'.
- Ensures 'cascade' appears in API documentation
- Does NOT actually filter the queryset directly
"""
return queryset
def filter_location(self, queryset, name, value):
"""Filter for location that also applies cascade logic."""
cascade = str2bool(self.data.get('cascade', True))
if value == 'null':
if not cascade:
return queryset.filter(location=None)
return queryset
if not cascade:
return queryset.filter(location=value)
try:
loc_obj = StockLocation.objects.get(pk=value)
except StockLocation.DoesNotExist:
return queryset
children = loc_obj.getUniqueChildren()
return queryset.filter(location__in=children)
class StockApiMixin: class StockApiMixin:
"""Mixin class for StockItem API endpoints.""" """Mixin class for StockItem API endpoints."""
@ -1191,52 +1252,6 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView):
headers=self.get_success_headers(serializer.data), headers=self.get_success_headers(serializer.data),
) )
def filter_queryset(self, queryset):
"""Custom filtering for the StockItem queryset."""
params = self.request.query_params
queryset = super().filter_queryset(queryset)
# Exclude stock item tree
exclude_tree = params.get('exclude_tree', None)
if exclude_tree is not None:
try:
item = StockItem.objects.get(pk=exclude_tree)
queryset = queryset.exclude(
pk__in=[it.pk for it in item.get_descendants(include_self=True)]
)
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
pass
# Does the client wish to filter by stock location?
loc_id = params.get('location', None)
cascade = str2bool(params.get('cascade', True))
if loc_id is not None:
# Filter by 'null' location (i.e. top-level items)
if isNull(loc_id):
if not cascade:
queryset = queryset.filter(location=None)
else:
try:
# If '?cascade=true' then include items which exist in sub-locations
if cascade:
location = StockLocation.objects.get(pk=loc_id)
queryset = queryset.filter(
location__in=location.getUniqueChildren()
)
else:
queryset = queryset.filter(location=loc_id)
except (ValueError, StockLocation.DoesNotExist): # pragma: no cover
pass
return queryset
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_field_aliases = { ordering_field_aliases = {

View File

@ -274,18 +274,19 @@
tree_id: 9 tree_id: 9
level: 0 level: 0
lft: 1 lft: 1
rght: 2 rght: 4
- model: stock.stockitem - model: stock.stockitem
pk: 1001 pk: 1001
fields: fields:
part: 100 part: 100
parent: 1000
location: 1 location: 1
quantity: 11 quantity: 11
tree_id: 14 tree_id: 9
level: 0 level: 1
lft: 1 lft: 2
rght: 2 rght: 3
- model: stock.stockitem - model: stock.stockitem
pk: 1002 pk: 1002

View File

@ -16,11 +16,11 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='stockitem', model_name='stockitem',
name='purchase_price', name='purchase_price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'), field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency='', help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'),
), ),
migrations.AddField( migrations.AddField(
model_name='stockitem', model_name='stockitem',
name='purchase_price_currency', name='purchase_price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.currency.all_currency_codes(), default=common.currency.currency_code_default(), editable=False, max_length=3), field=djmoney.models.fields.CurrencyField(choices=common.currency.all_currency_codes(), default='', editable=False, max_length=3),
), ),
] ]

View File

@ -2,7 +2,6 @@
from django.db import migrations from django.db import migrations
import djmoney.models.fields import djmoney.models.fields
import common.currency
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -15,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='stockitem', model_name='stockitem',
name='purchase_price', name='purchase_price',
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'), field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency='', help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'),
), ),
] ]

View File

@ -329,7 +329,7 @@ class StockItemSerializer(
'supplier_part_detail.MPN', 'supplier_part_detail.MPN',
] ]
import_exclude_fields = ['use_pack_size', 'location_path'] import_exclude_fields = ['location_path', 'serial_numbers', 'use_pack_size']
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -490,7 +490,7 @@ class StockItemSerializer(
def update(self, instance, validated_data): def update(self, instance, validated_data):
"""Custom update method to pass the user information through to the instance.""" """Custom update method to pass the user information through to the instance."""
instance._user = self.context['user'] instance._user = self.context.get('user', None)
status_custom_key = validated_data.pop('status_custom_key', None) status_custom_key = validated_data.pop('status_custom_key', None)
status = validated_data.pop('status', None) status = validated_data.pop('status', None)

View File

@ -621,6 +621,13 @@ class StockItemListTest(StockAPITestCase):
response = self.get_stock(location=7) response = self.get_stock(location=7)
self.assertEqual(len(response), 18) self.assertEqual(len(response), 18)
def test_filter_by_exclude_tree(self):
"""Filter StockItem by excluding a StockItem tree."""
response = self.get_stock(exclude_tree=1000)
for item in response:
self.assertNotEqual(item['pk'], 1000)
self.assertNotEqual(item['parent'], 1000)
def test_filter_by_depleted(self): def test_filter_by_depleted(self):
"""Filter StockItem by depleted status.""" """Filter StockItem by depleted status."""
response = self.get_stock(depleted=1) response = self.get_stock(depleted=1)
@ -786,10 +793,10 @@ class StockItemListTest(StockAPITestCase):
def test_filter_has_child_items(self): def test_filter_has_child_items(self):
"""Filter StockItem by has_child_items.""" """Filter StockItem by has_child_items."""
response = self.get_stock(has_child_items=True) response = self.get_stock(has_child_items=True)
self.assertEqual(len(response), 0) self.assertEqual(len(response), 1)
response = self.get_stock(has_child_items=False) response = self.get_stock(has_child_items=False)
self.assertEqual(len(response), 29) # TODO: adjust test dataset (belongs_to) self.assertEqual(len(response), 28) # TODO: adjust test dataset (belongs_to)
def test_filter_sent_to_customer(self): def test_filter_sent_to_customer(self):
"""Filter StockItem by sent_to_customer.""" """Filter StockItem by sent_to_customer."""

View File

@ -6,6 +6,8 @@ from django.core.exceptions import ValidationError
from django.db.models import Sum from django.db.models import Sum
from django.test import override_settings from django.test import override_settings
from djmoney.money import Money
from build.models import Build from build.models import Build
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from company.models import Company from company.models import Company
@ -803,6 +805,27 @@ class StockTest(StockTestBase):
self.assertTrue(check_func()) self.assertTrue(check_func())
def test_purchase_price(self):
"""Test purchase price field."""
from common.currency import currency_code_default
from common.settings import set_global_setting
part = Part.objects.filter(virtual=False).first()
for currency in ['AUD', 'USD', 'JPY']:
set_global_setting('INVENTREE_DEFAULT_CURRENCY', currency)
self.assertEqual(currency_code_default(), currency)
# Create stock item, do not specify currency - should get default
item = StockItem.objects.create(part=part, quantity=10)
self.assertEqual(item.purchase_price_currency, currency)
# Create stock item, specify currency
item = StockItem.objects.create(
part=part, quantity=10, purchase_price=Money(5, 'GBP')
)
self.assertEqual(item.purchase_price_currency, 'GBP')
class StockBarcodeTest(StockTestBase): class StockBarcodeTest(StockTestBase):
"""Run barcode tests for the stock app.""" """Run barcode tests for the stock app."""

View File

@ -144,6 +144,14 @@ class UserDetail(RetrieveUpdateDestroyAPI):
serializer_class = ExtendedUserSerializer serializer_class = ExtendedUserSerializer
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly] permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
def perform_destroy(self, instance):
"""Override destroy method to ensure sessions are deleted first."""
# Remove all sessions for this user
if sessions := instance.usersession_set.all():
sessions.delete()
# Normally delete the user
return super().perform_destroy(instance)
class UserDetailSetPassword(UpdateAPI): class UserDetailSetPassword(UpdateAPI):
"""Allows superusers to set the password for a user.""" """Allows superusers to set the password for a user."""

View File

@ -61,7 +61,10 @@ if settings.LDAP_AUTH:
user.save() user.save()
# if they got an email address from LDAP, create it now and make it the primary # if they got an email address from LDAP, create it now and make it the primary
if user.email: if (
user.email
and not EmailAddress.objects.filter(user=user, email=user.email).exists()
):
EmailAddress.objects.create(user=user, email=user.email, primary=True) EmailAddress.objects.create(user=user, email=user.email, primary=True)

View File

@ -1,5 +1,7 @@
"""Unit tests for the 'users' app.""" """Unit tests for the 'users' app."""
from time import sleep
from django.apps import apps from django.apps import apps
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.test import TestCase from django.test import TestCase
@ -349,12 +351,21 @@ class MFALoginTest(InvenTreeAPITestCase):
response = self.post(login_url, auth_data, expected_code=200) response = self.post(login_url, auth_data, expected_code=200)
self._helper_meta_val(response) self._helper_meta_val(response)
# Add MFA # Add MFA - trying in a limited loop in case of timing issues
response = self.post( success: bool = False
reverse('browser:mfa:manage_totp'), for _ in range(10):
{'code': self.get_topt()}, try:
expected_code=200, response = self.post(
) reverse('browser:mfa:manage_totp'),
{'code': self.get_topt()},
expected_code=200,
)
success = True
break
except AssertionError:
sleep(0.8)
self.assertTrue(success, 'Failed to add MFA device')
# There must be a TOTP device now - success # There must be a TOTP device now - success
self.get(reverse('browser:mfa:manage_totp'), expected_code=200) self.get(reverse('browser:mfa:manage_totp'), expected_code=200)
self.get(reverse('api-token'), expected_code=200) self.get(reverse('api-token'), expected_code=200)

View File

@ -13,7 +13,34 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root">
<div id="update-warning" style="display: none;">
If you see this text there might be an issue with your update.
<br>
See <a href="https://docs.inventree.org/en/stable/settings/error_codes/#inve-e1">INVE-E1</a> in the docs for help.
</div>
<div id="no-javascript-warning" style="display: none;">
<hr>
This application requires JavaScript to function correctly. Please enable JavaScript in your browser settings.
</div>
<noscript>
<!-- fallback if javascript is blocked -->
<style>
#update-warning { display: block !important; }
#no-javascript-warning { display: block !important; }
</style>
</noscript>
<script>
setTimeout(() => {
let warningElement = document.getElementById('update-warning');
if (warningElement) {
warningElement.style.display = 'block';
}
}, 1000);
</script>
</div>
<div id="spa_settings">{% spa_settings %}</div> <div id="spa_settings">{% spa_settings %}</div>
{% if bundle == "NOT_FOUND" %} {% if bundle == "NOT_FOUND" %}
<div id="spa_bundle_error"> <div id="spa_bundle_error">

View File

@ -185,95 +185,111 @@ click==8.1.8 \
--hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \ --hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \
--hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a --hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a
# via pip-tools # via pip-tools
coverage[toml]==7.10.6 \ coverage[toml]==7.10.7 \
--hash=sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930 \ --hash=sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9 \
--hash=sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747 \ --hash=sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880 \
--hash=sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65 \ --hash=sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999 \
--hash=sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7 \ --hash=sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1 \
--hash=sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27 \ --hash=sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13 \
--hash=sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034 \ --hash=sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b \
--hash=sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd \ --hash=sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82 \
--hash=sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b \ --hash=sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973 \
--hash=sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e \ --hash=sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f \
--hash=sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c \ --hash=sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681 \
--hash=sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5 \ --hash=sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0 \
--hash=sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc \ --hash=sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541 \
--hash=sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5 \ --hash=sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32 \
--hash=sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5 \ --hash=sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17 \
--hash=sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6 \ --hash=sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a \
--hash=sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634 \ --hash=sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40 \
--hash=sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003 \ --hash=sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd \
--hash=sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b \ --hash=sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6 \
--hash=sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7 \ --hash=sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7 \
--hash=sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb \ --hash=sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb \
--hash=sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619 \ --hash=sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f \
--hash=sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b \ --hash=sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d \
--hash=sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea \ --hash=sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe \
--hash=sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb \ --hash=sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c \
--hash=sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e \ --hash=sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807 \
--hash=sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc \ --hash=sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab \
--hash=sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32 \ --hash=sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2 \
--hash=sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb \ --hash=sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546 \
--hash=sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a \ --hash=sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e \
--hash=sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282 \ --hash=sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65 \
--hash=sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5 \ --hash=sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396 \
--hash=sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5 \ --hash=sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431 \
--hash=sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6 \ --hash=sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb \
--hash=sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356 \ --hash=sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699 \
--hash=sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a \ --hash=sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0 \
--hash=sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e \ --hash=sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f \
--hash=sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144 \ --hash=sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a \
--hash=sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb \ --hash=sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235 \
--hash=sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4 \ --hash=sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911 \
--hash=sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629 \ --hash=sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23 \
--hash=sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1 \ --hash=sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87 \
--hash=sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612 \ --hash=sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699 \
--hash=sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972 \ --hash=sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a \
--hash=sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393 \ --hash=sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b \
--hash=sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc \ --hash=sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256 \
--hash=sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352 \ --hash=sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a \
--hash=sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6 \ --hash=sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417 \
--hash=sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0 \ --hash=sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0 \
--hash=sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3 \ --hash=sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a \
--hash=sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80 \ --hash=sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360 \
--hash=sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9 \ --hash=sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0 \
--hash=sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78 \ --hash=sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b \
--hash=sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0 \ --hash=sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb \
--hash=sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a \ --hash=sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2 \
--hash=sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c \ --hash=sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d \
--hash=sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d \ --hash=sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a \
--hash=sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32 \ --hash=sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e \
--hash=sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0 \ --hash=sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69 \
--hash=sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80 \ --hash=sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14 \
--hash=sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713 \ --hash=sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d \
--hash=sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27 \ --hash=sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f \
--hash=sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153 \ --hash=sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2 \
--hash=sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c \ --hash=sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c \
--hash=sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460 \ --hash=sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0 \
--hash=sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1 \ --hash=sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399 \
--hash=sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f \ --hash=sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59 \
--hash=sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b \ --hash=sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63 \
--hash=sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945 \ --hash=sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b \
--hash=sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b \ --hash=sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2 \
--hash=sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a \ --hash=sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e \
--hash=sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df \ --hash=sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0 \
--hash=sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d \ --hash=sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520 \
--hash=sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21 \ --hash=sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df \
--hash=sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4 \ --hash=sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c \
--hash=sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2 \ --hash=sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b \
--hash=sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528 \ --hash=sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2 \
--hash=sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba \ --hash=sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f \
--hash=sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301 \ --hash=sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61 \
--hash=sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62 \ --hash=sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a \
--hash=sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2 \ --hash=sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59 \
--hash=sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d \ --hash=sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c \
--hash=sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf \ --hash=sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf \
--hash=sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e \ --hash=sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07 \
--hash=sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90 \ --hash=sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6 \
--hash=sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e \ --hash=sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e \
--hash=sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862 \ --hash=sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594 \
--hash=sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5 \ --hash=sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49 \
--hash=sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6 --hash=sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843 \
--hash=sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14 \
--hash=sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3 \
--hash=sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1 \
--hash=sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698 \
--hash=sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15 \
--hash=sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d \
--hash=sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5 \
--hash=sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e \
--hash=sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0 \
--hash=sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b \
--hash=sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239 \
--hash=sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba \
--hash=sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4 \
--hash=sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260 \
--hash=sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a \
--hash=sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3
# via -r src/backend/requirements-dev.in # via -r src/backend/requirements-dev.in
cryptography==44.0.3 \ cryptography==44.0.3 \
--hash=sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259 \ --hash=sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259 \
@ -320,9 +336,9 @@ distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv # via virtualenv
django==4.2.23 \ django==4.2.25 \
--hash=sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4 \ --hash=sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311 \
--hash=sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803 --hash=sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# django-slowtests # django-slowtests
@ -336,13 +352,13 @@ django-test-migrations==1.4.0 \
--hash=sha256:294dff98f6d43d020d4046b971bac5339e7c71458a35e9ad6450c388fe16ed6b \ --hash=sha256:294dff98f6d43d020d4046b971bac5339e7c71458a35e9ad6450c388fe16ed6b \
--hash=sha256:f0c9c92864ed27d0c9a582e92056637e91227f54bd868a50cb9a1726668c563e --hash=sha256:f0c9c92864ed27d0c9a582e92056637e91227f54bd868a50cb9a1726668c563e
# via -r src/backend/requirements-dev.in # via -r src/backend/requirements-dev.in
filelock==3.18.0 \ filelock==3.19.1 \
--hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \ --hash=sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58 \
--hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de --hash=sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d
# via virtualenv # via virtualenv
identify==2.6.12 \ identify==2.6.15 \
--hash=sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2 \ --hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
--hash=sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6 --hash=sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf
# via pre-commit # via pre-commit
importlib-metadata==8.7.0 \ importlib-metadata==8.7.0 \
--hash=sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000 \ --hash=sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000 \
@ -350,9 +366,10 @@ importlib-metadata==8.7.0 \
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# build # build
isort==6.0.1 \ # isort
--hash=sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450 \ isort==6.1.0 \
--hash=sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615 --hash=sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784 \
--hash=sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481
# via -r src/backend/requirements-dev.in # via -r src/backend/requirements-dev.in
nodeenv==1.9.1 \ nodeenv==1.9.1 \
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
@ -372,9 +389,9 @@ pip==25.2 \
--hash=sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2 \ --hash=sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2 \
--hash=sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717 --hash=sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717
# via pip-tools # via pip-tools
pip-tools==7.5.0 \ pip-tools==7.5.1 \
--hash=sha256:30639f50961bb09f49d22f4389e8d7d990709677c094ce1114186b1f2e9b5821 \ --hash=sha256:a051a94794ba52df9acad2d7c9b0b09ae001617db458a543f8287fea7b89c2cf \
--hash=sha256:69758e4e5a65f160e315d74db46246fdbb30d549f1ed0c4236d057122c9b0f18 --hash=sha256:f5ff803823529edc0e6e40c86b1aa7da7266fb1078093c8beea4e5b77877036a
# via -r src/backend/requirements-dev.in # via -r src/backend/requirements-dev.in
platformdirs==4.3.8 \ platformdirs==4.3.8 \
--hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \ --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \
@ -513,9 +530,10 @@ typing-extensions==4.14.1 \
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# asgiref # asgiref
# django-test-migrations # django-test-migrations
virtualenv==20.33.1 \ # virtualenv
--hash=sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67 \ virtualenv==20.34.0 \
--hash=sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8 --hash=sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026 \
--hash=sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a
# via pre-commit # via pre-commit
wheel==0.45.1 \ wheel==0.45.1 \
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \

View File

@ -391,9 +391,9 @@ defusedxml==0.7.1 \
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
# via python3-openid # via python3-openid
django==4.2.23 \ django==4.2.25 \
--hash=sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4 \ --hash=sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311 \
--hash=sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803 --hash=sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c
# via # via
# -r src/backend/requirements.in # -r src/backend/requirements.in
# django-allauth # django-allauth

View File

@ -1,4 +1,4 @@
import type { MantineSize } from '@mantine/core'; import type { MantineRadius, MantineSize } from '@mantine/core';
export type UiSizeType = MantineSize | string | number; export type UiSizeType = MantineSize | string | number;
@ -6,7 +6,7 @@ export interface UserTheme {
primaryColor: string; primaryColor: string;
whiteColor: string; whiteColor: string;
blackColor: string; blackColor: string;
radius: UiSizeType; radius: MantineRadius;
loader: string; loader: string;
} }

View File

@ -62,6 +62,8 @@ export function PrintingActions({
id: reportId id: reportId
}); });
const [itemIdList, setItemIdList] = useState<number[]>([]);
// Fetch available printing fields via OPTIONS request // Fetch available printing fields via OPTIONS request
const printingFields = useQuery({ const printingFields = useQuery({
enabled: labelPrintingEnabled, enabled: labelPrintingEnabled,
@ -89,13 +91,13 @@ export function PrintingActions({
filters: { filters: {
enabled: true, enabled: true,
model_type: modelType, model_type: modelType,
items: items.join(',') items: itemIdList.join(',')
} }
}; };
fields.items = { fields.items = {
...fields.items, ...fields.items,
value: items, value: itemIdList,
hidden: true hidden: true
}; };
@ -115,7 +117,7 @@ export function PrintingActions({
}; };
return fields; return fields;
}, [defaultLabelPlugin, pluginKey, printingFields.data, items]); }, [defaultLabelPlugin, pluginKey, printingFields.data, itemIdList]);
const labelModal = useCreateApiFormModal({ const labelModal = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.label_print), url: apiUrl(ApiEndpoints.label_print),
@ -123,6 +125,9 @@ export function PrintingActions({
modalId: 'print-labels', modalId: 'print-labels',
fields: labelFields, fields: labelFields,
timeout: 5000, timeout: 5000,
onOpen: () => {
setItemIdList(items);
},
onClose: () => { onClose: () => {
setPluginKey(''); setPluginKey('');
}, },
@ -134,24 +139,31 @@ export function PrintingActions({
} }
}); });
const reportModal = useCreateApiFormModal({ const reportFields: ApiFormFieldSet = useMemo(() => {
url: apiUrl(ApiEndpoints.report_print), return {
title: t`Print Report`,
modalId: 'print-reports',
timeout: 5000,
fields: {
template: { template: {
autoFill: true, autoFill: true,
filters: { filters: {
enabled: true, enabled: true,
model_type: modelType, model_type: modelType,
items: items.join(',') items: itemIdList.join(',')
} }
}, },
items: { items: {
hidden: true, hidden: true,
value: items value: itemIdList
} }
};
}, [itemIdList, modelType]);
const reportModal = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.report_print),
title: t`Print Report`,
modalId: 'print-reports',
timeout: 5000,
fields: reportFields,
onOpen: () => {
setItemIdList(items);
}, },
submitText: t`Print`, submitText: t`Print`,
successMessage: null, successMessage: null,

View File

@ -27,7 +27,7 @@ import {
IconDownload, IconDownload,
IconFilter IconFilter
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import type { CalendarState } from '../../hooks/UseCalendar'; import type { CalendarState } from '../../hooks/UseCalendar';
import { useLocalState } from '../../states/LocalState'; import { useLocalState } from '../../states/LocalState';
@ -59,6 +59,9 @@ export default function Calendar({
const [locale] = useLocalState(useShallow((s) => [s.language])); const [locale] = useLocalState(useShallow((s) => [s.language]));
// Ensure underscore is replaced with dash
const calendarLocale = useMemo(() => locale.replace('_', '-'), [locale]);
const selectMonth = useCallback( const selectMonth = useCallback(
(date: DateValue) => { (date: DateValue) => {
state.selectMonth(date); state.selectMonth(date);
@ -186,7 +189,7 @@ export default function Calendar({
plugins={[dayGridPlugin, interactionPlugin]} plugins={[dayGridPlugin, interactionPlugin]}
initialView='dayGridMonth' initialView='dayGridMonth'
locales={allLocales} locales={allLocales}
locale={locale} locale={calendarLocale}
headerToolbar={false} headerToolbar={false}
footerToolbar={false} footerToolbar={false}
{...calendarProps} {...calendarProps}

View File

@ -1,5 +1,13 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Alert, Card, Center, Divider, Loader, Text } from '@mantine/core'; import {
Alert,
Card,
Center,
Divider,
Loader,
Space,
Text
} from '@mantine/core';
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { IconExclamationCircle, IconInfoCircle } from '@tabler/icons-react'; import { IconExclamationCircle, IconInfoCircle } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
@ -21,15 +29,23 @@ export default function DashboardLayout() {
const [widgets, setWidgets] = useState<DashboardWidgetProps[]>([]); const [widgets, setWidgets] = useState<DashboardWidgetProps[]>([]);
// local/remote storage values for widget / layout // local/remote storage values for widget / layout
const [remoteWidgets, setRemoteWidgets, remoteLayouts, setRemoteLayouts] = const [
useLocalState( remoteWidgets,
useShallow((state) => [ setRemoteWidgets,
state.widgets, remoteLayouts,
state.setWidgets, setRemoteLayouts,
state.layouts, showSampleDashboard,
state.setLayouts setShowSampleDashboard
]) ] = useLocalState(
); useShallow((state) => [
state.widgets,
state.setWidgets,
state.layouts,
state.setLayouts,
state.showSampleDashboard,
state.setShowSampleDashboard
])
);
const [editing, setEditing] = useDisclosure(false); const [editing, setEditing] = useDisclosure(false);
const [removing, setRemoving] = useDisclosure(false); const [removing, setRemoving] = useDisclosure(false);
@ -75,6 +91,9 @@ export default function DashboardLayout() {
); );
if (newWidget) { if (newWidget) {
if (showSampleDashboard) {
setShowSampleDashboard(false);
}
setWidgets([...widgets, newWidget]); setWidgets([...widgets, newWidget]);
} }
@ -195,10 +214,48 @@ export default function DashboardLayout() {
// Clear all widgets from the dashboard // Clear all widgets from the dashboard
const clearWidgets = useCallback(() => { const clearWidgets = useCallback(() => {
if (showSampleDashboard) {
setShowSampleDashboard(false);
}
setWidgets([]); setWidgets([]);
setLayouts({}); setLayouts({});
}, []); }, []);
const defaultLayouts = {
lg: [
{
w: 6,
h: 4,
x: 0,
y: 0,
i: 'gstart',
minW: 5,
minH: 4,
moved: false,
static: false
},
{
w: 6,
h: 4,
x: 6,
y: 0,
i: 'news',
minW: 5,
minH: 4,
moved: false,
static: false
}
]
};
const loadWigs = ['news', 'gstart'];
const defaultWidgets = useMemo(() => {
return loadWigs
.map((lwid: string) =>
availableWidgets.items.find((wid) => wid.label === lwid)
)
.filter((widget): widget is DashboardWidgetProps => widget !== undefined);
}, [availableWidgets.items, defaultLayouts]);
return ( return (
<> <>
<DashboardWidgetDrawer <DashboardWidgetDrawer
@ -228,43 +285,41 @@ export default function DashboardLayout() {
{layouts && loaded && availableWidgets.loaded ? ( {layouts && loaded && availableWidgets.loaded ? (
<> <>
{widgetLabels.length == 0 ? ( {widgetLabels.length == 0 ? (
<Center> <>
<Card shadow='xs' padding='xl' style={{ width: '100%' }}> <Center>
<Alert <Card shadow='xs' padding='xl' style={{ width: '100%' }}>
color='blue' <Alert
title={t`No Widgets Selected`} color='blue'
icon={<IconInfoCircle />} title={t`No Widgets Selected`}
> icon={<IconInfoCircle />}
<Text>{t`Use the menu to add widgets to the dashboard`}</Text> >
</Alert> <Text>{t`Use the menu to add widgets to the dashboard`}</Text>
</Card> </Alert>
</Center> </Card>
</Center>
{showSampleDashboard && (
<>
<Space h='lg' />
{WidgetGrid(
defaultLayouts,
() => {},
editing,
defaultWidgets,
removing,
() => {}
)}
</>
)}
</>
) : ( ) : (
<ReactGridLayout WidgetGrid(
className='dashboard-layout' layouts,
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} onLayoutChange,
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} editing,
rowHeight={64} widgets,
layouts={layouts} removing,
onLayoutChange={onLayoutChange} removeWidget
compactType={'vertical'} )
isDraggable={editing}
isResizable={editing}
margin={[10, 10]}
containerPadding={[0, 0]}
resizeHandles={['ne', 'se', 'sw', 'nw']}
>
{widgets.map((item: DashboardWidgetProps) => {
return DashboardWidget({
item: item,
editing: editing,
removing: removing,
onRemove: () => {
removeWidget(item.label);
}
});
})}
</ReactGridLayout>
)} )}
</> </>
) : ( ) : (
@ -275,3 +330,40 @@ export default function DashboardLayout() {
</> </>
); );
} }
function WidgetGrid(
layouts: {},
onLayoutChange: (layout: any, newLayouts: any) => void,
editing: boolean,
widgets: DashboardWidgetProps[],
removing: boolean,
removeWidget: (widget: string) => void
) {
return (
<ReactGridLayout
className='dashboard-layout'
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
rowHeight={64}
layouts={layouts}
onLayoutChange={onLayoutChange}
compactType={'vertical'}
isDraggable={editing}
isResizable={editing}
margin={[10, 10]}
containerPadding={[0, 0]}
resizeHandles={['ne', 'se', 'sw', 'nw']}
>
{widgets.map((item: DashboardWidgetProps) => {
return DashboardWidget({
item: item,
editing: editing,
removing: removing,
onRemove: () => {
removeWidget(item.label);
}
});
})}
</ReactGridLayout>
);
}

View File

@ -24,14 +24,17 @@ export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
title: t`Subscribed Parts`, title: t`Subscribed Parts`,
description: t`Show the number of parts which you have subscribed to`, description: t`Show the number of parts which you have subscribed to`,
modelType: ModelType.part, modelType: ModelType.part,
params: { starred: true } params: { starred: true, active: true }
}), }),
QueryCountDashboardWidget({ QueryCountDashboardWidget({
label: 'sub-cat', label: 'sub-cat',
title: t`Subscribed Categories`, title: t`Subscribed Categories`,
description: t`Show the number of part categories which you have subscribed to`, description: t`Show the number of part categories which you have subscribed to`,
modelType: ModelType.partcategory, modelType: ModelType.partcategory,
params: { starred: true } params: {
starred: true,
top_level: 'none'
}
}), }),
QueryCountDashboardWidget({ QueryCountDashboardWidget({
label: 'invalid-bom', label: 'invalid-bom',

View File

@ -1,7 +1,7 @@
import type { ApiFormFieldType } from '@lib/types/Forms'; import type { ApiFormFieldType } from '@lib/types/Forms';
import { Select } from '@mantine/core'; import { Select } from '@mantine/core';
import { useId } from '@mantine/hooks'; import { useId } from '@mantine/hooks';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useRef } from 'react';
import type { FieldValues, UseControllerReturn } from 'react-hook-form'; import type { FieldValues, UseControllerReturn } from 'react-hook-form';
/** /**
@ -17,6 +17,7 @@ export function ChoiceField({
fieldName: string; fieldName: string;
}>) { }>) {
const fieldId = useId(); const fieldId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const { const {
field, field,
@ -70,6 +71,8 @@ export function ChoiceField({
error={definition.error ?? error?.message} error={definition.error ?? error?.message}
radius='sm' radius='sm'
{...field} {...field}
ref={inputRef}
onDropdownOpen={() => inputRef?.current?.select()}
onChange={onChange} onChange={onChange}
data={choices} data={choices}
value={choiceValue} value={choiceValue}
@ -81,6 +84,7 @@ export function ChoiceField({
leftSection={definition.icon} leftSection={definition.icon}
comboboxProps={{ withinPortal: true }} comboboxProps={{ withinPortal: true }}
searchable searchable
selectFirstOptionOnChange
/> />
); );
} }

View File

@ -285,7 +285,7 @@ export default function ImporterDataSelector({
<IconCircleDashedCheck color='blue' size={16} /> <IconCircleDashedCheck color='blue' size={16} />
)} )}
{!row.complete && !row.valid && ( {!row.complete && !row.valid && (
<HoverCard openDelay={50} closeDelay={100}> <HoverCard openDelay={50} closeDelay={100} position='top-start'>
<HoverCard.Target> <HoverCard.Target>
<IconExclamationCircle color='red' size={16} /> <IconExclamationCircle color='red' size={16} />
</HoverCard.Target> </HoverCard.Target>

View File

@ -68,27 +68,29 @@ const AboutContent = ({
}); });
function fillTable(lookup: AboutLookupRef[], data: any, alwaysLink = false) { function fillTable(lookup: AboutLookupRef[], data: any, alwaysLink = false) {
return lookup.map((map: AboutLookupRef, idx) => ( return lookup
<Table.Tr key={idx}> .filter((entry: AboutLookupRef) => !!data[entry.ref])
<Table.Td>{map.title}</Table.Td> .map((entry: AboutLookupRef, idx) => (
<Table.Td> <Table.Tr key={idx}>
<Group justify='space-between' gap='xs'> <Table.Td>{entry.title}</Table.Td>
{alwaysLink ? ( <Table.Td>
<Anchor href={data[map.ref]} target='_blank'> <Group justify='space-between' gap='xs'>
{data[map.ref]} {alwaysLink ? (
</Anchor> <Anchor href={data[entry.ref]} target='_blank'>
) : map.link ? ( {data[entry.ref]}
<Anchor href={map.link} target='_blank'> </Anchor>
{data[map.ref]} ) : entry.link ? (
</Anchor> <Anchor href={entry.link} target='_blank'>
) : ( {data[entry.ref]}
data[map.ref] </Anchor>
)} ) : (
{map.copy && <CopyButton value={data[map.ref]} />} data[entry.ref]
</Group> )}
</Table.Td> {entry.copy && <CopyButton value={data[entry.ref]} />}
</Table.Tr> </Group>
)); </Table.Td>
</Table.Tr>
));
} }
/* renderer */ /* renderer */
if (isLoading) return <Trans>Loading</Trans>; if (isLoading) return <Trans>Loading</Trans>;

View File

@ -15,7 +15,7 @@ import { type ReactNode, useCallback } from 'react';
import { ModelInformationDict } from '@lib/enums/ModelInformation'; import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { navigateToLink } from '@lib/functions/Navigation'; import { getBaseUrl, navigateToLink } from '@lib/functions/Navigation';
import type { import type {
ModelRendererDict, ModelRendererDict,
RenderInstanceProps RenderInstanceProps
@ -219,7 +219,10 @@ export function RenderInlineModel({
{prefix} {prefix}
{image && <Thumbnail src={image} size={18} />} {image && <Thumbnail src={image} size={18} />}
{url ? ( {url ? (
<Anchor href='' onClick={(event: any) => onClick(event)}> <Anchor
href={`/${getBaseUrl()}${url}`}
onClick={(event: any) => onClick(event)}
>
{primary} {primary}
</Anchor> </Anchor>
) : ( ) : (

View File

@ -194,7 +194,10 @@ export function TableStatusRenderer(
accessor?: string accessor?: string
): ((record: any) => any) | undefined { ): ((record: any) => any) | undefined {
return (record: any) => { return (record: any) => {
const status = resolveItem(record, accessor ?? 'status'); const status =
resolveItem(record, accessor ?? 'status') ??
resolveItem(record, 'status_custom_key') ??
resolveItem(record, 'status');
return ( return (
status && ( status && (

View File

@ -88,7 +88,14 @@ export function RenderStockItem(
const allocated: number = Math.max(0, instance?.allocated ?? 0); const allocated: number = Math.max(0, instance?.allocated ?? 0);
if (instance?.serial !== null && instance?.serial !== undefined) { // Determine if this item is serialized
const serialized: boolean =
instance?.quantity == 1 &&
instance?.serial !== null &&
instance?.serial !== undefined &&
instance?.serial !== '';
if (serialized) {
quantity_string += `${t`Serial Number`}: ${instance.serial}`; quantity_string += `${t`Serial Number`}: ${instance.serial}`;
} else if (allocated > 0) { } else if (allocated > 0) {
const available: number = Math.max(0, instance.quantity - allocated); const available: number = Math.max(0, instance.quantity - allocated);

View File

@ -27,7 +27,7 @@ export function getActions(navigate: NavigateFunction) {
id: 'dashboard', id: 'dashboard',
label: t`Dashboard`, label: t`Dashboard`,
description: t`Go to the InvenTree dashboard`, description: t`Go to the InvenTree dashboard`,
onClick: () => {}, // navigate(menuItems.dashboard.link), onClick: () => navigate('/'),
leftSection: <IconLink size='1.2rem' /> leftSection: <IconLink size='1.2rem' />
}, },
{ {

View File

@ -1,4 +1,4 @@
import type { MantineSize } from '@mantine/core'; import type { MantineRadius } from '@mantine/core';
export const emptyServerAPI = { export const emptyServerAPI = {
server: null, server: null,
@ -26,7 +26,7 @@ export const emptyServerAPI = {
export interface SiteMarkProps { export interface SiteMarkProps {
value: number; value: number;
label: MantineSize; label: MantineRadius;
} }
export const SizeMarks: SiteMarkProps[] = [ export const SizeMarks: SiteMarkProps[] = [

View File

@ -219,7 +219,7 @@ export function useBuildOrderOutputFields({
location: { location: {
value: location, value: location,
onValueChange: (value: any) => { onValueChange: (value: any) => {
setQuantity(value); setLocation(value);
} }
}, },
auto_allocate: { auto_allocate: {

View File

@ -212,7 +212,8 @@ export function useStockFields({
icon: <IconCurrencyDollar /> icon: <IconCurrencyDollar />
}, },
purchase_price_currency: { purchase_price_currency: {
icon: <IconCoins /> icon: <IconCoins />,
default: globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY')
}, },
packaging: { packaging: {
icon: <IconPackage /> icon: <IconPackage />

View File

@ -106,14 +106,37 @@ export async function doBasicLogin(
} }
}) })
.catch(async (err) => { .catch(async (err) => {
if (err?.response?.status == 401) { notifications.hide('auth-login-error');
await handlePossibleMFAError(err);
} else if (err?.response?.status == 409) { if (err?.response?.status) {
switch (err.response.status) {
case 401:
await handlePossibleMFAError(err);
break;
case 409:
notifications.show({
title: t`Already logged in`,
message: t`There is a conflicting session on the server for this browser. Please logout of that first.`,
color: 'red',
id: 'auth-login-error',
autoClose: false
});
break;
default:
notifications.show({
title: `${t`Login failed`} (${err.response.status})`,
message: t`Check your input and try again.`,
id: 'auth-login-error',
color: 'red'
});
break;
}
} else {
notifications.show({ notifications.show({
title: t`Already logged in`, title: t`Login failed`,
message: t`There is a conflicting session on the server for this browser. Please logout of that first.`, message: t`No response from server.`,
color: 'red', color: 'red',
autoClose: false id: 'login-error'
}); });
} }
}); });

View File

@ -52,7 +52,7 @@ export function usePluginPanels({
// API query to fetch initial information on available plugin panels // API query to fetch initial information on available plugin panels
const pluginQuery = useQuery({ const pluginQuery = useQuery({
enabled: pluginPanelsEnabled && !!model && id !== undefined, enabled: pluginPanelsEnabled && !!model && id !== undefined,
queryKey: ['custom-plugin-panels', model, id], queryKey: ['custom-plugin-panels', model, id, instance],
throwOnError: (error: any) => { throwOnError: (error: any) => {
console.error('ERR: Failed to fetch plugin panels'); console.error('ERR: Failed to fetch plugin panels');
return false; return false;

View File

@ -40,19 +40,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
); );
// radius // radius
function getMark(value: number) { function getRadiusFromValue(value: number) {
const obj = SizeMarks.find((mark) => mark.value === value); const obj = SizeMarks.find((mark) => mark.value === value);
if (obj) return obj; if (obj) return obj.label;
return SizeMarks[0]; return 'sm';
} }
function getDefaultRadius() {
const value = Number.parseInt(userTheme.radius.toString()); const [radius, setRadius] = useState(25);
return SizeMarks.some((mark) => mark.value === value) ? value : 50;
}
const [radius, setRadius] = useState(getDefaultRadius());
function changeRadius(value: number) { function changeRadius(value: number) {
const r = getRadiusFromValue(value);
setRadius(value); setRadius(value);
setTheme([{ key: 'radius', value: value.toString() }]);
setTheme([{ key: 'radius', value: r.toString() }]);
} }
return ( return (
@ -163,7 +163,7 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Slider <Slider
label={(val) => getMark(val).label} label={(val) => getRadiusFromValue(val)}
defaultValue={50} defaultValue={50}
step={25} step={25}
marks={SizeMarks} marks={SizeMarks}

View File

@ -238,17 +238,6 @@ export default function BuildDetail() {
icon: 'manufacturers', icon: 'manufacturers',
hidden: !build.external hidden: !build.external
}, },
{
type: 'text',
name: 'purchase_order',
label: t`Purchase Order`,
icon: 'purchase_orders',
copy: true,
hidden: !build.external,
value_formatter: () => {
return 'TODO: external PO';
}
},
{ {
type: 'text', type: 'text',
name: 'reference', name: 'reference',
@ -690,6 +679,7 @@ export default function BuildDetail() {
<PrintingActions <PrintingActions
modelType={ModelType.build} modelType={ModelType.build}
items={[build.pk]} items={[build.pk]}
enableLabels
enableReports enableReports
/>, />,
<OptionsActionDropdown <OptionsActionDropdown

View File

@ -32,7 +32,7 @@ import { InvenTreeTable } from '../../tables/InvenTreeTable';
function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) { function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) {
const formattedLabel: string = useMemo(() => { const formattedLabel: string = useMemo(() => {
if (label && typeof label === 'number') { if (label && typeof label === 'number') {
return formatDate(dayjs().format('YYYY-MM-DD')) ?? label; return formatDate(dayjs(label).format('YYYY-MM-DD')) ?? label;
} else if (!!label) { } else if (!!label) {
return label.toString(); return label.toString();
} else { } else {
@ -190,7 +190,7 @@ export default function PartStockHistoryDetail({
enableBulkDelete: true, enableBulkDelete: true,
params: { params: {
part: partId, part: partId,
ordering: 'date' ordering: '-date'
}, },
rowActions: rowActions rowActions: rowActions
}} }}
@ -225,7 +225,7 @@ export default function PartStockHistoryDetail({
type: 'number', type: 'number',
domain: chartLimits, domain: chartLimits,
tickFormatter: (value: number) => { tickFormatter: (value: number) => {
return formatDate(dayjs().format('YYYY-MM-DD')); return formatDate(dayjs(value).format('YYYY-MM-DD'));
} }
}} }}
series={[ series={[

View File

@ -47,6 +47,8 @@ interface PricingOverviewEntry {
min_value: number | null | undefined; min_value: number | null | undefined;
max_value: number | null | undefined; max_value: number | null | undefined;
visible?: boolean; visible?: boolean;
min_currency?: string | null | undefined;
max_currency?: string | null | undefined;
currency?: string | null | undefined; currency?: string | null | undefined;
} }
@ -161,7 +163,8 @@ export default function PricingOverviewPanel({
return '-'; return '-';
} }
return formatCurrency(record?.min_value, { return formatCurrency(record?.min_value, {
currency: record.currency ?? pricing?.currency currency:
record.min_currency ?? record.currency ?? pricing?.currency
}); });
} }
}, },
@ -174,7 +177,8 @@ export default function PricingOverviewPanel({
} }
return formatCurrency(record?.max_value, { return formatCurrency(record?.max_value, {
currency: record.currency ?? pricing?.currency currency:
record.max_currency ?? record.currency ?? pricing?.currency
}); });
} }
} }
@ -189,6 +193,9 @@ export default function PricingOverviewPanel({
icon: <IconExclamationCircle />, icon: <IconExclamationCircle />,
min_value: Number.parseFloat(pricing?.override_min), min_value: Number.parseFloat(pricing?.override_min),
max_value: Number.parseFloat(pricing?.override_max), max_value: Number.parseFloat(pricing?.override_max),
min_currency: pricing?.override_min_currency ?? pricing?.currency,
max_currency: pricing?.override_max_currency ?? pricing?.currency,
currency: pricing?.currency,
valid: pricing?.override_min != null && pricing?.override_max != null valid: pricing?.override_min != null && pricing?.override_max != null
}, },
{ {

View File

@ -460,6 +460,7 @@ export default function PurchaseOrderDetail() {
<PrintingActions <PrintingActions
modelType={ModelType.purchaseorder} modelType={ModelType.purchaseorder}
items={[order.pk]} items={[order.pk]}
enableLabels
enableReports enableReports
/>, />,
<OptionsActionDropdown <OptionsActionDropdown

View File

@ -458,6 +458,7 @@ export default function ReturnOrderDetail() {
modelType={ModelType.returnorder} modelType={ModelType.returnorder}
items={[order.pk]} items={[order.pk]}
enableReports enableReports
enableLabels
/>, />,
<OptionsActionDropdown <OptionsActionDropdown
tooltip={t`Order Actions`} tooltip={t`Order Actions`}

View File

@ -516,6 +516,7 @@ export default function SalesOrderDetail() {
modelType={ModelType.salesorder} modelType={ModelType.salesorder}
items={[order.pk]} items={[order.pk]}
enableReports enableReports
enableLabels
/>, />,
<OptionsActionDropdown <OptionsActionDropdown
tooltip={t`Order Actions`} tooltip={t`Order Actions`}

View File

@ -173,17 +173,12 @@ export default function StockDetail() {
stockitem.status_custom_key == stockitem.status stockitem.status_custom_key == stockitem.status
}, },
{ {
type: 'text', type: 'link',
name: 'updated', name: 'link',
icon: 'calendar', label: t`Link`,
label: t`Last Updated` external: true,
}, copy: true,
{ hidden: !stockitem.link
type: 'text',
name: 'stocktake',
icon: 'calendar',
label: t`Last Stocktake`,
hidden: !stockitem.stocktake
} }
]; ];
@ -415,6 +410,19 @@ export default function StockDetail() {
icon: 'part', icon: 'part',
label: t`Packaging`, label: t`Packaging`,
hidden: !stockitem.packaging hidden: !stockitem.packaging
},
{
type: 'text',
name: 'updated',
icon: 'calendar',
label: t`Last Updated`
},
{
type: 'text',
name: 'stocktake',
icon: 'calendar',
label: t`Last Stocktake`,
hidden: !stockitem.stocktake
} }
]; ];

View File

@ -31,6 +31,8 @@ interface LocalStateProps {
setWidgets: (widgets: string[], noPatch?: boolean) => void; setWidgets: (widgets: string[], noPatch?: boolean) => void;
layouts: any; layouts: any;
setLayouts: (layouts: any, noPatch?: boolean) => void; setLayouts: (layouts: any, noPatch?: boolean) => void;
showSampleDashboard: boolean;
setShowSampleDashboard: (value: boolean) => void;
// panels // panels
lastUsedPanels: Record<string, string>; lastUsedPanels: Record<string, string>;
setLastUsedPanel: (panelKey: string) => (value: string) => void; setLastUsedPanel: (panelKey: string) => (value: string) => void;
@ -118,6 +120,10 @@ export const useLocalState = create<LocalStateProps>()(
if (!noPatch) if (!noPatch)
patchUser('widgets', { widgets: get().widgets, layouts: newLayouts }); patchUser('widgets', { widgets: get().widgets, layouts: newLayouts });
}, },
showSampleDashboard: true,
setShowSampleDashboard: (value) => {
set({ showSampleDashboard: value });
},
// panels // panels
lastUsedPanels: {}, lastUsedPanels: {},
setLastUsedPanel: (panelKey) => (value) => { setLastUsedPanel: (panelKey) => (value) => {

View File

@ -368,14 +368,14 @@ export type StatusColumnProps = TableColumnProps & {
}; };
export function StatusColumn(props: StatusColumnProps): TableColumn { export function StatusColumn(props: StatusColumnProps): TableColumn {
const accessor: string = props.accessor ?? 'status'; const accessor: string = props.accessor ?? 'status_custom_key';
return { return {
accessor: 'status', accessor: 'status',
sortable: true, sortable: true,
switchable: true, switchable: true,
minWidth: '50px', minWidth: '50px',
render: TableStatusRenderer(props.model, accessor ?? 'status_custom_key'), render: TableStatusRenderer(props.model, accessor),
...props ...props
}; };
} }

View File

@ -352,6 +352,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
// Reset the pagination state when the search term changes // Reset the pagination state when the search term changes
useEffect(() => { useEffect(() => {
tableState.setPage(1); tableState.setPage(1);
tableState.clearSelectedRecords();
}, [ }, [
tableState.searchTerm, tableState.searchTerm,
tableState.filterSet.activeFilters, tableState.filterSet.activeFilters,

View File

@ -250,7 +250,10 @@ export default function InvenTreeTableHeader({
<HoverCard <HoverCard
position='bottom-end' position='bottom-end'
withinPortal={true} withinPortal={true}
disabled={!tableState.filterSet.activeFilters?.length} disabled={
hasCustomFilters ||
!tableState.filterSet.activeFilters?.length
}
> >
<HoverCard.Target> <HoverCard.Target>
<Tooltip <Tooltip

View File

@ -325,7 +325,7 @@ export function BomTable({
if (on_order > 0) { if (on_order > 0) {
extra.push( extra.push(
<Text key='on_order'> <Text key='on_order'>
{t`On order`}: {on_order} {t`On order`}: {formatDecimal(on_order)}
</Text> </Text>
); );
} }
@ -333,7 +333,7 @@ export function BomTable({
if (building > 0) { if (building > 0) {
extra.push( extra.push(
<Text key='building'> <Text key='building'>
{t`Building`}: {building} {t`Building`}: {formatDecimal(building)}
</Text> </Text>
); );
} }

View File

@ -183,9 +183,13 @@ export default function BuildAllocatedStockTable({
const [selectedItems, setSelectedItems] = useState<any[]>([]); const [selectedItems, setSelectedItems] = useState<any[]>([]);
const itemsToConsume = useMemo(() => {
return selectedItems.filter((item) => !item.part_detail?.trackable);
}, [selectedItems]);
const consumeStock = useConsumeBuildItemsForm({ const consumeStock = useConsumeBuildItemsForm({
buildId: buildId ?? 0, buildId: buildId ?? 0,
allocatedItems: selectedItems, allocatedItems: itemsToConsume,
onFormSuccess: () => { onFormSuccess: () => {
table.clearSelectedRecords(); table.clearSelectedRecords();
table.refreshTable(); table.refreshTable();
@ -225,13 +229,16 @@ export default function BuildAllocatedStockTable({
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
const part = record.part_detail ?? {};
const trackable: boolean = part?.trackable ?? false;
return [ return [
{ {
color: 'green', color: 'green',
icon: <IconCircleDashedCheck />, icon: <IconCircleDashedCheck />,
title: t`Consume`, title: t`Consume`,
tooltip: t`Consume Stock`, tooltip: t`Consume Stock`,
hidden: !user.hasChangeRole(UserRoles.build), hidden: !buildId || trackable || !user.hasChangeRole(UserRoles.build),
onClick: () => { onClick: () => {
setSelectedItems([record]); setSelectedItems([record]);
consumeStock.open(); consumeStock.open();

View File

@ -479,6 +479,7 @@ export default function BuildLineTable({
); );
} }
const allocated = record.allocatedQuantity ?? 0;
let required = Math.max(0, record.quantity - record.consumed); let required = Math.max(0, record.quantity - record.consumed);
if (output?.pk) { if (output?.pk) {
@ -486,7 +487,7 @@ export default function BuildLineTable({
required = record.bom_item_detail?.quantity; required = record.bom_item_detail?.quantity;
} }
if (required <= 0) { if (allocated <= 0 && required <= 0) {
return ( return (
<Group gap='xs' wrap='nowrap'> <Group gap='xs' wrap='nowrap'>
<IconCircleCheck size={16} color='green' /> <IconCircleCheck size={16} color='green' />
@ -502,7 +503,7 @@ export default function BuildLineTable({
return ( return (
<ProgressBar <ProgressBar
progressLabel={true} progressLabel={true}
value={record.allocatedQuantity} value={allocated}
maximum={required} maximum={required}
/> />
); );
@ -664,9 +665,10 @@ export default function BuildLineTable({
(record: any): RowAction[] => { (record: any): RowAction[] => {
const part = record.part_detail ?? {}; const part = record.part_detail ?? {};
const in_production = build.status == buildStatus.PRODUCTION; const in_production = build.status == buildStatus.PRODUCTION;
const consumable = record.bom_item_detail?.consumable ?? false; const consumable: boolean = record.bom_item_detail?.consumable ?? false;
const trackable: boolean = part?.trackable ?? false;
const hasOutput = !!output?.pk; const hasOutput: boolean = !!output?.pk;
const required = Math.max( const required = Math.max(
0, 0,
@ -677,6 +679,7 @@ export default function BuildLineTable({
const canConsume = const canConsume =
in_production && in_production &&
!consumable && !consumable &&
!trackable &&
record.allocated > 0 && record.allocated > 0 &&
user.hasChangeRole(UserRoles.build); user.hasChangeRole(UserRoles.build);
@ -952,6 +955,9 @@ export default function BuildLineTable({
dataFormatter: formatRecords, dataFormatter: formatRecords,
enableDownload: true, enableDownload: true,
enableSelection: true, enableSelection: true,
enableLabels: true,
modelType: ModelType.buildline,
onCellClick: () => {},
rowExpansion: rowExpansion rowExpansion: rowExpansion
}} }}
/> />

View File

@ -248,6 +248,7 @@ export function BuildOrderTable({
modelType: ModelType.build, modelType: ModelType.build,
enableSelection: true, enableSelection: true,
enableReports: true, enableReports: true,
enableLabels: true,
enableDownload: true enableDownload: true
}} }}
/> />

View File

@ -23,6 +23,7 @@ import type { TableColumn } from '@lib/types/Tables';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ImporterDrawer from '../../components/importer/ImporterDrawer'; import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { RenderInstance } from '../../components/render/Instance'; import { RenderInstance } from '../../components/render/Instance';
import { formatCurrency } from '../../defaults/formatters';
import { dataImporterSessionFields } from '../../forms/ImporterForms'; import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { import {
usePurchaseOrderLineItemFields, usePurchaseOrderLineItemFields,
@ -249,11 +250,15 @@ export function PurchaseOrderLineItemTable({
accessor: 'purchase_price', accessor: 'purchase_price',
title: t`Unit Price` title: t`Unit Price`
}), }),
CurrencyColumn({ {
accessor: 'total_price', accessor: 'total_price',
currency_accessor: 'purchase_price_currency', title: t`Total Price`,
title: t`Total Price` render: (record: any) =>
}), formatCurrency(record.purchase_price, {
currency: record.purchase_price_currency,
multiplier: record.quantity
})
},
TargetDateColumn({}), TargetDateColumn({}),
LocationColumn({ LocationColumn({
accessor: 'destination_detail', accessor: 'destination_detail',

View File

@ -190,7 +190,8 @@ export function PurchaseOrderTable({
modelType: ModelType.purchaseorder, modelType: ModelType.purchaseorder,
enableSelection: true, enableSelection: true,
enableDownload: true, enableDownload: true,
enableReports: true enableReports: true,
enableLabels: true
}} }}
/> />
</> </>

View File

@ -189,7 +189,8 @@ export function ReturnOrderTable({
modelType: ModelType.returnorder, modelType: ModelType.returnorder,
enableSelection: true, enableSelection: true,
enableDownload: true, enableDownload: true,
enableReports: true enableReports: true,
enableLabels: true
}} }}
/> />
</> </>

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