Compare commits

..

157 Commits

Author SHA1 Message Date
Jan Böhmer
ab92620f56 Merge remote-tracking branch 'origin/l10n_master' 2025-09-23 23:39:29 +02:00
Jan Böhmer
0a4b873b77 New translations messages.en.xlf (German) 2025-09-23 23:38:41 +02:00
Jan Böhmer
23bafa4471 Bumped version to 2.2.0 2025-09-23 23:38:32 +02:00
Jan Böhmer
436d3df83f New Crowdin updates (#1050)
* New translations messages.en.xlf (English)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (German)
2025-09-23 23:34:51 +02:00
Jan Böhmer
37393dd6c9 Revert "Removed more unused translations"
This reverts commit 8c15af3105.
2025-09-23 23:12:31 +02:00
Jan Böhmer
8c15af3105 Removed more unused translations 2025-09-23 23:11:48 +02:00
Jan Böhmer
0ac1d19415 Removed unused translations related to bulk imports 2025-09-23 23:06:30 +02:00
Jan Böhmer
63a33d1057 Fixed deprecations 2025-09-23 20:55:22 +02:00
Jan Böhmer
a9d0caad5f Fixed some deprecations 2025-09-23 00:03:04 +02:00
Jan Böhmer
6ed4ad4c8c Use native lazy objects for doctrine when on PHP8.4 2025-09-22 23:52:31 +02:00
Jan Böhmer
71946afd75 Updated dependencies 2025-09-22 22:45:58 +02:00
Jan Böhmer
919bf49ec1 Fix the wrong currency code mouser returns for chinese yuan
This fixes issue #1045
2025-09-22 00:20:52 +02:00
Jan Böhmer
001f2e97ea Do not let phpunit fail on deprecations 2025-09-22 00:12:29 +02:00
Jan Böhmer
d2d5490aab Merge remote-tracking branch 'origin/master' 2025-09-22 00:06:25 +02:00
Jan Böhmer
c788fa99e3 Use an older version of maennchen/zipstream-php which is capable of running on 32-bit systems 2025-09-22 00:06:21 +02:00
Jan Böhmer
34d284b1c4 Do not test against real LCSC provider... 2025-09-22 00:05:49 +02:00
dependabot[bot]
67c736f979 Bump actions/setup-node from 4 to 5 (#1034)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-21 23:52:02 +02:00
Jan Böhmer
6b1e7b3544 Run phpunit tests with PHP8.5 2025-09-21 23:51:36 +02:00
Jan Böhmer
da30a6657e Use the new LCSC endpoint for batch searches 2025-09-21 23:51:11 +02:00
Jan Böhmer
2e0b5edd95 Merge remote-tracking branch 'origin/master' 2025-09-21 23:43:16 +02:00
Jan Böhmer
1d6f0b403a Moved scoll to see more actions hint into optgroup title 2025-09-21 23:43:10 +02:00
Jan Böhmer
df65f39d5e Fixed phpunit tests 2025-09-21 23:33:56 +02:00
Jan Böhmer
1bfea3c48a Fixed phpstan issues 2025-09-21 23:27:56 +02:00
Jan Böhmer
07db1554c7 Updated dependencies 2025-09-21 23:15:28 +02:00
Jan Böhmer
ed1e51f694 Merge branch 'feature/batch-info-provider-import' 2025-09-21 23:14:09 +02:00
Jan Böhmer
5b71d68179 Added tests for new DTO objects 2025-09-21 23:07:45 +02:00
Jan Böhmer
b94e28a961 Added a bit of documentation about the new features 2025-09-21 22:52:23 +02:00
Jan Böhmer
1d52b7c464 Fixed tests 2025-09-21 21:35:13 +02:00
Jan Böhmer
0d49632b92 Refactored constraints, to reuse existing mechanisms 2025-09-21 20:45:18 +02:00
Jan Böhmer
702e5c8732 Use underscore in route paths instead of hyphens to match the other path styles 2025-09-21 19:58:15 +02:00
Jan Böhmer
d2b605edc0 Imrpoved bulk info provider manage page 2025-09-21 19:54:40 +02:00
Jan Böhmer
4c28871283 Fixed problem of failing researchAllParts
This maybe should be revisited in the future, but for now this fix should work
2025-09-21 19:47:49 +02:00
Jan Böhmer
1d38c50abc Fixed step2 template 2025-09-21 19:30:49 +02:00
Jan Böhmer
710569daaf Fixed phpunit tests 2025-09-21 19:03:29 +02:00
Jan Böhmer
92cd645945 Renamed dto to make their relation to batch searches more clear 2025-09-21 17:49:00 +02:00
Jan Böhmer
16126c4000 Encapsulate the fieldmapping data in the importjob further 2025-09-21 17:41:56 +02:00
Jan Böhmer
eda6deff47 Made classes readonly where possible 2025-09-21 14:25:57 +02:00
Jan Böhmer
27a18bdc1e Doing refactoring to remove remains of arrays 2025-09-21 14:24:34 +02:00
Jan Böhmer
98b62cc81e Do not autowire bulkImport parameters globally 2025-09-20 14:33:16 +02:00
barisgit
2c195d9767 Refactor bulk info provider: replace complex arrays with DTOs
- Add BulkSearchResponseDTO, FieldMappingDTO for type safety
- Use composition instead of inheritance in BulkSearchResultDTO
- Remove unnecessary BulkSearchRequestDTO
- Fix N+1 queries and API error handling
- Fix Add Mapping button functionality
2025-09-19 16:28:40 +02:00
Jan Böhmer
bb49c67108 Removed Microsoft X-XSS-Protection header, as it is not recommended on modern browsers anymore and is considered deprecated 2025-09-19 09:18:49 +02:00
Jan Böhmer
f0dc80aac9 New Crowdin updates (#1036)
* New translations messages.en.xlf (German)

* New translations validators.en.xlf (German)

* New translations messages.en.xlf (German)

* New translations security.en.xlf (German)

* New translations validators.en.xlf (Dutch)

* New translations messages.en.xlf (German)

* New translations messages.en.xlf (German)

* New translations security.en.xlf (German)

* New translations messages.en.xlf (German)
2025-09-19 09:12:47 +02:00
Jan Böhmer
8998b006e0 Added some type hints for arrays 2025-09-14 23:17:43 +02:00
Jan Böhmer
b4b758c356 Fixed tests 2025-09-14 23:14:00 +02:00
Jan Böhmer
a399b629d1 Use a proper range constraint on the form
Otherwise it is possible to inject invalid data
2025-09-14 23:04:44 +02:00
Jan Böhmer
41a7238ab7 Pass parts object directly to BulkSearchRequestDTO and added some syntax hints 2025-09-14 22:56:12 +02:00
Jan Böhmer
0e99faee0a Moved BulkImportJobStatus enum to own file to make it discoverable by autoloading 2025-09-14 22:23:07 +02:00
Jan Böhmer
13e75808f8 Use validateJobAccess where applicable and ensure permissions for all controller endpoints 2025-09-14 16:24:56 +02:00
Jan Böhmer
1a0fab0615 Use a deterministic method to generate parameter names for filters, to allow for proper caching of queries 2025-09-09 23:05:03 +02:00
Jan Böhmer
fcdeb0479a Bumped version 2.1.2 2025-09-09 21:31:15 +02:00
Jan Böhmer
79ac318d0f Merge remote-tracking branch 'origin/l10n_master' 2025-09-09 21:30:22 +02:00
Jan Böhmer
6765c110c6 New translations messages.en.xlf (English) 2025-09-09 21:23:52 +02:00
Jan Böhmer
f6f83cc111 New translations messages.en.xlf (Czech) 2025-09-09 21:23:41 +02:00
d-buchmann
c6d5fb3f57 Update translations
When the part count notice is always displayed, the exclamation mark would probably be perceived as rather annoying.
(Of course this would have to be reflected in crowdin)
2025-09-09 21:20:42 +02:00
Jan Böhmer
4b8ef4b0fa Allow the defaultSearchProviders option to be empty
This fixes issue #1032
2025-09-09 21:19:12 +02:00
barisgit
46d8c86e0c Improved makefile 2025-09-09 20:57:58 +02:00
barisgit
c7102bcd8c Update bulk info provider test to work with new services approach 2025-09-09 20:54:27 +02:00
barisgit
d6ac16ede0 Refactor bulk import functionality to make controller smaller (use services) add DTOs and use stimulus controllers on frontend 2025-09-09 20:30:27 +02:00
Jan Böhmer
23cad8261b New translations messages.en.xlf (Czech) 2025-09-09 14:12:30 +02:00
barisgit
65d840c444 Fix invalid flag --memory-limit 2025-09-09 10:31:33 +02:00
Jan Böhmer
c52126ccf8 Update PHP version badge in README to 8.2 2025-09-08 12:53:03 +02:00
Jan Böhmer
8eec606589 Bumped to version 2.1.1 2025-09-07 23:58:41 +02:00
Jan Böhmer
cdc58507db Removed style nonce, as it blocks the loading of all other inline styles and kills the styling of the sidebar treeviews 2025-09-07 23:58:21 +02:00
Jan Böhmer
03f7ad66d2 Bumped version to 2.1.0 2025-09-07 23:16:15 +02:00
Jan Böhmer
3b01af1247 Merge branch 'l10n_master' 2025-09-07 23:15:53 +02:00
Jan Böhmer
8d2ff6f5d7 New translations messages.en.xlf (English) 2025-09-07 22:37:16 +02:00
Jan Böhmer
6ff7f64384 New translations messages.en.xlf (German) 2025-09-07 22:37:11 +02:00
Jan Böhmer
c2cbbee0df Ensure that part table action bar dont overlap our navbar dropdowns 2025-09-07 21:59:30 +02:00
Jan Böhmer
e81c8470be Made part table action bar sticky floating
Related to PR #997
2025-09-07 21:52:04 +02:00
Jan Böhmer
ecd2abe00e Made image size of preview images in tables configurable and slightly bigger by default
This makes PR #984 and #623 obsolete
2025-09-07 21:21:08 +02:00
Jan Böhmer
0d1ae030be Allow to select default info providers for search
This fixes issue #556
2025-09-07 20:42:33 +02:00
Jan Böhmer
1f669a9c53 Readded option to show all elements in a table 2025-09-07 20:04:48 +02:00
Jan Böhmer
8ff2fc5a82 Allow to disable the extraction of parameters out of part description and notes
Fixes issue #747
2025-09-07 19:55:58 +02:00
Jan Böhmer
c7ec8adc31 Disable settings caching in debug mode
Otherwise we run into errors, if a settings get changed
2025-09-07 19:44:32 +02:00
Jan Böhmer
cee6d355e8 Allow to hide the version number on homepage 2025-09-07 19:43:23 +02:00
Jan Böhmer
4b00697f02 Allow to customize which items get shown on the homepage and in which order
This fixes issue #470 and #894
2025-09-07 19:27:02 +02:00
Jan Böhmer
617ae03b48 Merge remote-tracking branch 'origin/master' 2025-09-07 17:56:30 +02:00
Jan Böhmer
71629a696c Use updated gnu unifont 2025-09-07 17:55:55 +02:00
Jan Böhmer
14cc0b9e9a New translations messages.en.xlf (German) (#1028) 2025-09-07 17:53:12 +02:00
Jan Böhmer
c5a1df37b9 Fixed tests 2025-09-07 00:26:24 +02:00
Jan Böhmer
46d1a0cb1b Added an button to update a label profile from directly inside the label generator
Related to issue #994
2025-09-06 23:57:04 +02:00
Jan Böhmer
a18ec373d2 Validate label profiles before creating them via the label controller, so that we do not create duplicate entries
This fixes issue #994
2025-09-06 23:49:14 +02:00
Jan Böhmer
ced16620ec Fixed pollin info provider
This fixes issue #1015
2025-09-06 23:42:09 +02:00
Jan Böhmer
890621b651 Merge branch 'feature/upload-schematic-boms' 2025-09-06 23:34:55 +02:00
Jan Böhmer
5a5691a8c4 Added documentation about the new BOM file types 2025-09-06 23:34:47 +02:00
Jan Böhmer
fb92db8c05 Use body element as dropdownParent for tomselect elements
This improves UX
2025-09-06 23:32:08 +02:00
Jan Böhmer
2b28aa8ba9 Enable CSP also in debug mode, as otherwise it complains about missing nonce function 2025-09-06 23:29:19 +02:00
Jan Böhmer
90f83273da Added nonce to scripts to ensure that they are working with enabled CSP 2025-09-06 23:24:32 +02:00
Jan Böhmer
76f3c379b5 Added generic CSV type option, to highlight the universal nature of the importer 2025-09-06 23:20:07 +02:00
Jan Böhmer
1d33d95c57 Show validation error messages in mapping step 2025-09-06 23:10:47 +02:00
Jan Böhmer
72e3766be5 Added missing translations that got removed during rebase 2025-09-06 23:10:12 +02:00
barisgit
7c1ab6460d Add tests to cover new additions 2025-09-06 19:54:13 +02:00
barisgit
d0f2422e0d Implement functionality to import schematic csv (or any other csv for that matter), with ability to map input columns to output columns with input validation and error handling 2025-09-06 19:54:13 +02:00
barisgit
4277f42285 Fix same error as in other branch and add makefile 2025-09-06 19:53:56 +02:00
d-buchmann
0e9558e331 Do not mark internal (relative) links as external and open in new tab in markdown blocks
Don't handle links as external by default. Instead distiguish internal (relative) and external (absolute) links.
2025-09-06 19:49:38 +02:00
d-buchmann
4e9e82d9f1 Replace "range" indicators with mathematical tilde in LCSC provider (#989)
* Replace "range" indicators with mathematical tilde symbols in LCSC provider

* Improve comment
2025-09-06 19:43:50 +02:00
Jan Böhmer
411ac500ba New Crowdin updates (#1008)
* New translations messages.en.xlf (Czech)

* New translations messages.en.xlf (Czech)

* New translations messages.en.xlf (Czech)

* New translations messages.en.xlf (Czech)

* New translations messages.en.xlf (German)

* New translations messages.en.xlf (German)

* New translations messages.en.xlf (German)
2025-09-06 19:43:05 +02:00
d-buchmann
b1443a817b Add import permission for label profiles (#1021) 2025-09-06 19:42:07 +02:00
Jan Böhmer
3e8ca06177 Fixed text color in ckeditor editors when in dark mode
Fixes issue #1016
2025-09-06 19:34:31 +02:00
Jan Böhmer
c1b7272ab1 Updated frontend dependencies 2025-09-06 19:30:17 +02:00
Jan Böhmer
b093866d15 Do not replace LCSC category slashes with arrows, as these are actually their names, not level separators 2025-09-06 19:27:10 +02:00
Jan Böhmer
065ef9f8ae Fixed LCSC provider
LCSC has changed its search API, so it was broken. Fixes issue #1018
2025-09-06 19:22:59 +02:00
Jan Böhmer
9b17efc12c Fixed phpstan issue 2025-09-06 00:39:23 +02:00
Jan Böhmer
fe7910a2f2 Fixed invalid name for currency in data fixture 2025-09-06 00:39:16 +02:00
Jan Böhmer
eb4258053e Added voter reason explaination to the other voters 2025-09-06 00:24:55 +02:00
Jan Böhmer
117ff4484d Allow to show what permissions a user is lacking in case of access denied message
Should help with errors like 1026
2025-09-06 00:10:50 +02:00
Jan Böhmer
ba7d139f8a Grey out info provider settings button if the user misses system settings permission
Helps to make the problem in #1026 more clear
2025-09-05 23:33:05 +02:00
Jan Böhmer
d657b2ff04 Merge remote-tracking branch 'd-buchmann/fix-formatting-mass-creation' 2025-09-05 23:26:17 +02:00
Jan Böhmer
0637c05053 Merge remote-tracking branch 'd-buchmann/sqlite-min-version' 2025-09-05 23:26:13 +02:00
Jan Böhmer
88fbc46325 Added test for Currency Admin Controller 2025-09-05 23:25:20 +02:00
Jan Böhmer
379155e839 Allow for more currency exchange rate pairs, without need for fixer.io 2025-09-05 22:15:04 +02:00
Jan Böhmer
0717239296 Use central banks of czechia, turkey and romania as a free provider for their currencies exchange rates 2025-09-05 19:56:01 +02:00
Jan Böhmer
d3e3c4e3f8 Fixed currency admin page and modernized underlying swap packages
Fixes issue #1009
2025-09-05 19:35:58 +02:00
d-buchmann
c9a1febc56 Fix formatting: Use literal '->' in CDATA context 2025-09-04 14:59:40 +02:00
d-buchmann
7f099972e1 Documentation - Require SQLite 3.35
That way, migrations that try to drop columns won't fail anymore (regardless if the user intended to use sqlite or not)
2025-09-04 14:50:59 +02:00
Jan Böhmer
52444e05e4 Optimized LCSC batch search calls and extracted it into interface for potential general use in the future 2025-08-31 23:41:16 +02:00
Jan Böhmer
4fcd55748f Use new settings object in LCSCProvider 2025-08-31 23:27:53 +02:00
Jan Böhmer
d57107ed3e Do not use ob_* functions in XSLX exporter, as this affects global state and can lead to sideffects 2025-08-31 23:05:07 +02:00
Jan Böhmer
0c7aa5e92a Fixed phpunit tests 2025-08-31 22:56:10 +02:00
Jan Böhmer
17f123ba8a Fixed logentryRepositoryTest
It seems that this was always wrong, but this was never noticed, because normally the log timestamps are all the same
2025-08-31 22:51:47 +02:00
Jan Böhmer
1156bb52af Added phpoffice dependency 2025-08-31 22:50:56 +02:00
barisgit
71be75b3e7 Improve test coverage 2025-08-31 22:18:25 +02:00
barisgit
5a4f151ca3 Add BulkInfoProviderImportJobPart to element type name generator 2025-08-31 22:18:25 +02:00
barisgit
9729a43f2b Add bulk_info_provider_import_job_part.label 2025-08-31 22:18:24 +02:00
barisgit
4da403569c Increase time limit on batch search and add option to priorities which fields to choose 2025-08-31 22:18:24 +02:00
barisgit
74be016b68 Add abbility to search faster on LCSC without details 2025-08-31 22:18:24 +02:00
barisgit
3896d3d9ab Fix a single failing test 2025-08-31 22:18:24 +02:00
barisgit
ed396765c8 Let symfony manage translations 2025-08-31 22:18:24 +02:00
barisgit
cc9d50a8fe Add makefile to help with development setup, change part_ids in bulk import jobs to junction table and implement filtering based on bulk import jobs status and its associated parts' statuses. 2025-08-31 22:17:05 +02:00
barisgit
9b4d5e9c27 Improve test coverage 2025-08-31 22:16:28 +02:00
barisgit
ccb837e4b4 Fix migration error and dto error 2025-08-31 22:16:28 +02:00
barisgit
2bc39e7791 Add tests and fix static errors 2025-08-31 22:16:27 +02:00
barisgit
fa7f3a1da1 Fix tests 2025-08-31 22:16:27 +02:00
barisgit
c91d37d2a4 More sophisticated two-step bulk import from info providers 2025-08-31 22:16:27 +02:00
barisgit
5ab7ac4d4b Move pageSize and table columns filter buttons apart a bit 2025-08-31 22:16:27 +02:00
barisgit
4c8940f9c3 Simple batch processing 2025-08-31 22:16:27 +02:00
barisgit
aa29f10d51 Remove problematic tests 2025-08-31 22:15:58 +02:00
barisgit
78885ec3c5 Add more tests and fix failing ones 2025-08-31 22:15:58 +02:00
barisgit
1fb137e89f Add export functionality to batch select and fix errors 2025-08-31 22:15:58 +02:00
barisgit
facfb37383 Implement excel based import/export 2025-08-31 22:15:58 +02:00
barisgit
c5751b2aa6 Fix timestamp test 2025-08-31 22:13:54 +02:00
barisgit
aa4299041b Update example import csv to schow real capatibilities 2025-08-31 22:13:54 +02:00
barisgit
c27f2246a3 Update part merger to consider rows with same supplier and spn duplicates 2025-08-31 22:13:54 +02:00
Jan Böhmer
a6be786d5d Bump to version 2.0.2 2025-08-31 15:20:22 +02:00
Jan Böhmer
578a030175 Reverted english translations which were broken by the PR 2025-08-31 15:19:05 +02:00
Jan Böhmer
f858e68f12 Merge remote-tracking branch 'origin/l10n_master' 2025-08-31 15:14:37 +02:00
Jan Böhmer
bdd88700d4 Start php-fpm directly in our docker entrypoint
This way it gets all environment variables and we do not need to hassle ourselves with the generation of php-fpm config files and we can use the normal clear_env=no option

This fixes issue #1006
2025-08-31 15:13:45 +02:00
Jan Böhmer
87cf75f67d New translations security.en.xlf (Czech) 2025-08-31 15:12:32 +02:00
Jan Böhmer
c3cc7cb0d6 New translations validators.en.xlf (Czech) 2025-08-31 15:12:31 +02:00
Jan Böhmer
e1600cdec9 New translations messages.en.xlf (Czech) 2025-08-31 15:12:30 +02:00
Jan Böhmer
431cf23600 Do not pollute docker logs with deprecation notices in error case 2025-08-31 15:11:31 +02:00
Jan Böhmer
08ce1795fc Use correct column for ordering when the columns were reordered 2025-08-31 01:44:26 +02:00
Jan Böhmer
e369ce6db9 Disable searching option on datatables which we do not need and which causes an CSP violation 2025-08-31 01:34:31 +02:00
Jan Böhmer
af4ea17faa Fixed formatting error in english translations 2025-08-31 01:22:19 +02:00
Jan Böhmer
cc70e77dee Revert "New translations messages.en.xlf (German)"
This reverts commit 50f478f7ef.
2025-08-30 22:15:27 +02:00
Jan Böhmer
b19cc13897 New translations messages.en.xlf (English) 2025-08-30 21:59:36 +02:00
Jan Böhmer
50f478f7ef New translations messages.en.xlf (German) 2025-08-30 21:59:33 +02:00
184 changed files with 28611 additions and 12528 deletions

View File

@@ -20,25 +20,6 @@
set -e
# Pass all environment variables to PHP-FPM
# Path where PHP-FPM pool configs live
PHP_FPM_ENV_CONF="/etc/php/PHP_VERSION/fpm/pool.d/99-env.conf"
# start fresh
echo "; auto-generated env config" > "$PHP_FPM_ENV_CONF"
echo "[www]" >> "$PHP_FPM_ENV_CONF"
echo "clear_env = no" >> "$PHP_FPM_ENV_CONF"
# add all container envs
printenv | while IFS='=' read -r name value; do
case "$name" in
HOSTNAME|PWD|SHLVL|PATH|_*) continue ;;
esac
# write literal value in quotes
echo "env[$name] = \"$value\"" >> "$PHP_FPM_ENV_CONF"
done
# recursive chowns can take a while, so we'll just do it if the owner is wrong
# Chown uploads/ folder if it does not belong to www-data
@@ -59,7 +40,7 @@ if [ -d /var/www/html/var/db ]; then
fi
# Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile)
service phpPHP_VERSION-fpm start
php-fpmPHP_VERSION -F &
# Run migrations if automigration is enabled via env variable DB_AUTOMIGRATE

View File

@@ -60,7 +60,7 @@ jobs:
${{ runner.os }}-yarn-
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '20'

View File

@@ -21,7 +21,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php-versions: ['8.2', '8.3', '8.4' ]
php-versions: ['8.2', '8.3', '8.4', '8.5' ]
db-type: [ 'mysql', 'sqlite', 'postgres' ]
env:
@@ -104,7 +104,7 @@ jobs:
run: composer install --prefer-dist --no-progress
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '20'

3
.gitignore vendored
View File

@@ -48,3 +48,6 @@ yarn-error.log
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
.claude/
CLAUDE.md

91
Makefile Normal file
View File

@@ -0,0 +1,91 @@
# PartDB Makefile for Test Environment Management
.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \
test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \
section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset
# Default target
help: ## Show this help
@awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
# Dependencies
deps-install: ## Install PHP dependencies with unlimited memory
@echo "📦 Installing PHP dependencies..."
COMPOSER_MEMORY_LIMIT=-1 composer install
yarn install
@echo "✅ Dependencies installed"
# Complete test environment setup
test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures)
@echo "✅ Test environment setup complete!"
# Clean test environment
test-clean: ## Clean test cache and database files
@echo "🧹 Cleaning test environment..."
rm -rf var/cache/test
rm -f var/app_test.db
@echo "✅ Test environment cleaned"
# Create test database
test-db-create: ## Create test database (if not exists)
@echo "🗄️ Creating test database..."
-php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
# Run database migrations for test environment
test-db-migrate: ## Run database migrations for test environment
@echo "🔄 Running database migrations..."
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test
# Clear test cache
test-cache-clear: ## Clear test cache
@echo "🗑️ Clearing test cache..."
rm -rf var/cache/test
@echo "✅ Test cache cleared"
# Load test fixtures
test-fixtures: ## Load test fixtures
@echo "📦 Loading test fixtures..."
php bin/console partdb:fixtures:load -n --env test
# Run PHPUnit tests
test-run: ## Run PHPUnit tests
@echo "🧪 Running tests..."
php bin/phpunit
# Quick test reset (clean + migrate + fixtures, skip DB creation)
test-reset: test-cache-clear test-db-migrate test-fixtures
@echo "✅ Test environment reset complete!"
test-typecheck: ## Run static analysis (PHPStan)
@echo "🧪 Running type checks..."
COMPOSER_MEMORY_LIMIT=-1 composer phpstan
# Development helpers
dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup)
@echo "✅ Development environment setup complete!"
dev-clean: ## Clean development cache and database files
@echo "🧹 Cleaning development environment..."
rm -rf var/cache/dev
rm -f var/app_dev.db
@echo "✅ Development environment cleaned"
dev-db-create: ## Create development database (if not exists)
@echo "🗄️ Creating development database..."
-php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
dev-db-migrate: ## Run database migrations for development environment
@echo "🔄 Running database migrations..."
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev
dev-cache-clear: ## Clear development cache
@echo "🗑️ Clearing development cache..."
rm -rf var/cache/dev
@echo "✅ Development cache cleared"
dev-warmup: ## Warm up development cache
@echo "🔥 Warming up development cache..."
COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n
dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate)
@echo "✅ Development environment reset complete!"

View File

@@ -3,7 +3,7 @@
![Static analysis](https://github.com/Part-DB/Part-DB-symfony/workflows/Static%20analysis/badge.svg)
[![codecov](https://codecov.io/gh/Part-DB/Part-DB-server/branch/master/graph/badge.svg)](https://codecov.io/gh/Part-DB/Part-DB-server)
![GitHub License](https://img.shields.io/github/license/Part-DB/Part-DB-symfony)
![PHP Version](https://img.shields.io/badge/PHP-%3E%3D%208.1-green)
![PHP Version](https://img.shields.io/badge/PHP-%3E%3D%208.2-green)
![Docker Pulls](https://img.shields.io/docker/pulls/jbtronics/part-db1)
![Docker Build Status](https://github.com/Part-DB/Part-DB-symfony/workflows/Docker%20Image%20Build/badge.svg)

View File

@@ -1 +1 @@
2.0.0
2.2.0

View File

@@ -0,0 +1,359 @@
import { Controller } from "@hotwired/stimulus"
import { generateCsrfHeaders } from "./csrf_protection_controller"
export default class extends Controller {
static targets = ["progressBar", "progressText"]
static values = {
jobId: Number,
partId: Number,
researchUrl: String,
researchAllUrl: String,
markCompletedUrl: String,
markSkippedUrl: String,
markPendingUrl: String
}
connect() {
// Auto-refresh progress if job is in progress
if (this.hasProgressBarTarget) {
this.startProgressUpdates()
}
// Restore scroll position after page reload (if any)
this.restoreScrollPosition()
}
getHeaders() {
const headers = {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
// Add CSRF headers if available
const form = document.querySelector('form')
if (form) {
const csrfHeaders = generateCsrfHeaders(form)
Object.assign(headers, csrfHeaders)
}
return headers
}
async fetchWithErrorHandling(url, options = {}, timeout = 30000) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...options,
headers: { ...this.getHeaders(), ...options.headers },
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Server error (${response.status}): ${errorText}`)
}
return await response.json()
} catch (error) {
clearTimeout(timeoutId)
if (error.name === 'AbortError') {
throw new Error('Request timed out. Please try again.')
} else if (error.message.includes('Failed to fetch')) {
throw new Error('Network error. Please check your connection and try again.')
} else {
throw error
}
}
}
disconnect() {
if (this.progressInterval) {
clearInterval(this.progressInterval)
}
}
startProgressUpdates() {
// Progress updates are handled via page reload for better reliability
// No need for periodic updates since state changes trigger page refresh
}
restoreScrollPosition() {
const savedPosition = sessionStorage.getItem('bulkImportScrollPosition')
if (savedPosition) {
// Restore scroll position after a small delay to ensure page is fully loaded
setTimeout(() => {
window.scrollTo(0, parseInt(savedPosition))
// Clear the saved position so it doesn't interfere with normal navigation
sessionStorage.removeItem('bulkImportScrollPosition')
}, 100)
}
}
async markCompleted(event) {
const partId = event.currentTarget.dataset.partId
try {
const url = this.markCompletedUrlValue.replace('__PART_ID__', partId)
const data = await this.fetchWithErrorHandling(url, { method: 'POST' })
if (data.success) {
this.updateProgressDisplay(data)
this.markRowAsCompleted(partId)
if (data.job_completed) {
this.showJobCompletedMessage()
}
} else {
this.showErrorMessage(data.error || 'Failed to mark part as completed')
}
} catch (error) {
console.error('Error marking part as completed:', error)
this.showErrorMessage(error.message || 'Failed to mark part as completed')
}
}
async markSkipped(event) {
const partId = event.currentTarget.dataset.partId
const reason = prompt('Reason for skipping (optional):') || ''
try {
const url = this.markSkippedUrlValue.replace('__PART_ID__', partId)
const data = await this.fetchWithErrorHandling(url, {
method: 'POST',
body: JSON.stringify({ reason })
})
if (data.success) {
this.updateProgressDisplay(data)
this.markRowAsSkipped(partId)
} else {
this.showErrorMessage(data.error || 'Failed to mark part as skipped')
}
} catch (error) {
console.error('Error marking part as skipped:', error)
this.showErrorMessage(error.message || 'Failed to mark part as skipped')
}
}
async markPending(event) {
const partId = event.currentTarget.dataset.partId
try {
const url = this.markPendingUrlValue.replace('__PART_ID__', partId)
const data = await this.fetchWithErrorHandling(url, { method: 'POST' })
if (data.success) {
this.updateProgressDisplay(data)
this.markRowAsPending(partId)
} else {
this.showErrorMessage(data.error || 'Failed to mark part as pending')
}
} catch (error) {
console.error('Error marking part as pending:', error)
this.showErrorMessage(error.message || 'Failed to mark part as pending')
}
}
updateProgressDisplay(data) {
if (this.hasProgressBarTarget) {
this.progressBarTarget.style.width = `${data.progress}%`
this.progressBarTarget.setAttribute('aria-valuenow', data.progress)
}
if (this.hasProgressTextTarget) {
this.progressTextTarget.textContent = `${data.completed_count} / ${data.total_count} completed`
}
}
markRowAsCompleted(partId) {
// Save scroll position and refresh page to show updated state
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
window.location.reload()
}
markRowAsSkipped(partId) {
// Save scroll position and refresh page to show updated state
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
window.location.reload()
}
markRowAsPending(partId) {
// Save scroll position and refresh page to show updated state
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
window.location.reload()
}
showJobCompletedMessage() {
const alert = document.createElement('div')
alert.className = 'alert alert-success alert-dismissible fade show'
alert.innerHTML = `
<i class="fas fa-check-circle"></i>
Job completed! All parts have been processed.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`
const container = document.querySelector('.card-body')
container.insertBefore(alert, container.firstChild)
}
async researchPart(event) {
event.preventDefault()
event.stopPropagation()
const partId = event.currentTarget.dataset.partId
const spinner = event.currentTarget.querySelector(`[data-research-spinner="${partId}"]`)
const button = event.currentTarget
// Show loading state
if (spinner) {
spinner.style.display = 'inline-block'
}
button.disabled = true
try {
const url = this.researchUrlValue.replace('__PART_ID__', partId)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
const response = await fetch(url, {
method: 'POST',
headers: this.getHeaders(),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Server error (${response.status}): ${errorText}`)
}
const data = await response.json()
if (data.success) {
this.showSuccessMessage(`Research completed for part. Found ${data.results_count} results.`)
// Save scroll position and reload to show updated results
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
window.location.reload()
} else {
this.showErrorMessage(data.error || 'Research failed')
}
} catch (error) {
console.error('Error researching part:', error)
if (error.name === 'AbortError') {
this.showErrorMessage('Research timed out. Please try again.')
} else if (error.message.includes('Failed to fetch')) {
this.showErrorMessage('Network error. Please check your connection and try again.')
} else {
this.showErrorMessage(error.message || 'Research failed due to an unexpected error')
}
} finally {
// Hide loading state
if (spinner) {
spinner.style.display = 'none'
}
button.disabled = false
}
}
async researchAllParts(event) {
event.preventDefault()
event.stopPropagation()
const spinner = document.getElementById('research-all-spinner')
const button = event.currentTarget
// Show loading state
if (spinner) {
spinner.style.display = 'inline-block'
}
button.disabled = true
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 120000) // 2 minute timeout for bulk operations
const response = await fetch(this.researchAllUrlValue, {
method: 'POST',
headers: this.getHeaders(),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Server error (${response.status}): ${errorText}`)
}
const data = await response.json()
if (data.success) {
this.showSuccessMessage(`Research completed for ${data.researched_count} parts.`)
// Save scroll position and reload to show updated results
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
window.location.reload()
} else {
this.showErrorMessage(data.error || 'Bulk research failed')
}
} catch (error) {
console.error('Error researching all parts:', error)
if (error.name === 'AbortError') {
this.showErrorMessage('Bulk research timed out. This may happen with large batches. Please try again or process smaller batches.')
} else if (error.message.includes('Failed to fetch')) {
this.showErrorMessage('Network error. Please check your connection and try again.')
} else {
this.showErrorMessage(error.message || 'Bulk research failed due to an unexpected error')
}
} finally {
// Hide loading state
if (spinner) {
spinner.style.display = 'none'
}
button.disabled = false
}
}
showSuccessMessage(message) {
this.showToast('success', message)
}
showErrorMessage(message) {
this.showToast('error', message)
}
showToast(type, message) {
// Create a simple alert that doesn't disrupt layout
const alertId = 'alert-' + Date.now()
const iconClass = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle'
const alertClass = type === 'success' ? 'alert-success' : 'alert-danger'
const alertHTML = `
<div class="alert ${alertClass} alert-dismissible fade show position-fixed"
style="top: 20px; right: 20px; z-index: 9999; max-width: 400px;"
id="${alertId}">
<i class="fas ${iconClass} me-2"></i>
${message}
<button type="button" class="btn-close" onclick="this.parentElement.remove()" aria-label="Close"></button>
</div>
`
// Add alert to body
document.body.insertAdjacentHTML('beforeend', alertHTML)
// Auto-remove after 5 seconds
setTimeout(() => {
const alertElement = document.getElementById(alertId)
if (alertElement) {
alertElement.remove()
}
}, 5000)
}
}

View File

@@ -0,0 +1,92 @@
import { Controller } from "@hotwired/stimulus"
import { generateCsrfHeaders } from "./csrf_protection_controller"
export default class extends Controller {
static values = {
deleteUrl: String,
stopUrl: String,
deleteConfirmMessage: String,
stopConfirmMessage: String
}
connect() {
// Controller initialized
}
getHeaders() {
const headers = {
'X-Requested-With': 'XMLHttpRequest'
}
// Add CSRF headers if available
const form = document.querySelector('form')
if (form) {
const csrfHeaders = generateCsrfHeaders(form)
Object.assign(headers, csrfHeaders)
}
return headers
}
async deleteJob(event) {
const jobId = event.currentTarget.dataset.jobId
const confirmMessage = this.deleteConfirmMessageValue || 'Are you sure you want to delete this job?'
if (confirm(confirmMessage)) {
try {
const deleteUrl = this.deleteUrlValue.replace('__JOB_ID__', jobId)
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: this.getHeaders()
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const data = await response.json()
if (data.success) {
location.reload()
} else {
alert('Error deleting job: ' + (data.error || 'Unknown error'))
}
} catch (error) {
console.error('Error deleting job:', error)
alert('Error deleting job: ' + error.message)
}
}
}
async stopJob(event) {
const jobId = event.currentTarget.dataset.jobId
const confirmMessage = this.stopConfirmMessageValue || 'Are you sure you want to stop this job?'
if (confirm(confirmMessage)) {
try {
const stopUrl = this.stopUrlValue.replace('__JOB_ID__', jobId)
const response = await fetch(stopUrl, {
method: 'POST',
headers: this.getHeaders()
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const data = await response.json()
if (data.success) {
location.reload()
} else {
alert('Error stopping job: ' + (data.error || 'Unknown error'))
}
} catch (error) {
console.error('Error stopping job:', error)
alert('Error stopping job: ' + error.message)
}
}
}
}

View File

@@ -56,12 +56,16 @@ export default class MarkdownController extends Controller {
this.element.innerHTML = DOMPurify.sanitize(MarkdownController._marked.parse(this.unescapeHTML(raw)));
for(let a of this.element.querySelectorAll('a')) {
//Mark all links as external
a.classList.add('link-external');
//Open links in new tag
a.setAttribute('target', '_blank');
//Dont track
a.setAttribute('rel', 'noopener');
// test if link is absolute
var r = new RegExp('^(?:[a-z+]+:)?//', 'i');
if (r.test(a.getAttribute('href'))) {
//Mark all links as external
a.classList.add('link-external');
//Open links in new tag
a.setAttribute('target', '_blank');
//Dont track
a.setAttribute('rel', 'noopener');
}
}
//Apply bootstrap styles to tables
@@ -108,4 +112,4 @@ export default class MarkdownController extends Controller {
gfm: true,
});
}*/
}
}

View File

@@ -42,6 +42,7 @@ export default class extends Controller {
selectOnTab: true,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
dropdownParent: 'body',
render: {
item: (data, escape) => {
return '<span>' + escape(data.label) + '</span>';

View File

@@ -45,8 +45,10 @@ export default class extends DatatablesController {
//Hide/Unhide panel with the selection tools
if (count > 0) {
selectPanel.classList.remove('d-none');
selectPanel.classList.add('sticky-select-bar');
} else {
selectPanel.classList.add('d-none');
selectPanel.classList.remove('sticky-select-bar');
}
//Update selection count text

View File

@@ -16,6 +16,7 @@ export default class extends Controller {
searchField: ["name", "description", "category", "footprint"],
valueField: "id",
labelField: "name",
dropdownParent: 'body',
preload: "focus",
render: {
item: (data, escape) => {
@@ -71,4 +72,4 @@ export default class extends Controller {
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}
}

View File

@@ -44,6 +44,7 @@ export default class extends Controller {
allowEmptyOption: true,
selectOnTab: true,
maxOptions: null,
dropdownParent: 'body',
render: {
item: this.renderItem.bind(this),
@@ -108,4 +109,4 @@ export default class extends Controller {
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}
}

View File

@@ -29,6 +29,7 @@ export default class extends Controller {
this._tomSelect = new TomSelect(this.element, {
maxItems: 1000,
allowEmptyOption: true,
dropdownParent: 'body',
plugins: ['remove_button'],
});
}
@@ -39,4 +40,4 @@ export default class extends Controller {
this._tomSelect.destroy();
}
}
}

View File

@@ -50,6 +50,7 @@ export default class extends Controller {
valueField: 'text',
searchField: 'text',
orderField: 'text',
dropdownParent: 'body',
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',

View File

@@ -54,6 +54,7 @@ export default class extends Controller {
maxItems: 1,
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
splitOn: null,
dropdownParent: 'body',
searchField: [
{field: "text", weight : 2},

View File

@@ -43,6 +43,7 @@ export default class extends Controller {
selectOnTab: true,
createOnBlur: true,
create: true,
dropdownParent: 'body',
};
if(this.element.dataset.autocomplete) {
@@ -73,4 +74,4 @@ export default class extends Controller {
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}
}

View File

@@ -0,0 +1,136 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["tbody", "addButton", "submitButton"]
static values = {
mappingIndex: Number,
maxMappings: Number,
prototype: String,
maxMappingsReachedMessage: String
}
connect() {
this.updateAddButtonState()
this.updateFieldOptions()
this.attachEventListeners()
}
attachEventListeners() {
// Add event listeners to existing field selects
const fieldSelects = this.tbodyTarget.querySelectorAll('select[name*="[field]"]')
fieldSelects.forEach(select => {
select.addEventListener('change', this.updateFieldOptions.bind(this))
})
// Note: Add button click is handled by Stimulus action in template (data-action="click->field-mapping#addMapping")
// No manual event listener needed
// Form submit handler
const form = this.element.querySelector('form')
if (form && this.hasSubmitButtonTarget) {
form.addEventListener('submit', this.handleFormSubmit.bind(this))
}
}
addMapping() {
const currentMappings = this.tbodyTarget.querySelectorAll('.mapping-row').length
if (currentMappings >= this.maxMappingsValue) {
alert(this.maxMappingsReachedMessageValue)
return
}
const newRowHtml = this.prototypeValue.replace(/__name__/g, this.mappingIndexValue)
const tempDiv = document.createElement('div')
tempDiv.innerHTML = newRowHtml
const fieldWidget = tempDiv.querySelector('select[name*="[field]"]') || tempDiv.children[0]
const providerWidget = tempDiv.querySelector('select[name*="[providers]"]') || tempDiv.children[1]
const priorityWidget = tempDiv.querySelector('input[name*="[priority]"]') || tempDiv.children[2]
const newRow = document.createElement('tr')
newRow.className = 'mapping-row'
newRow.innerHTML = `
<td>${fieldWidget ? fieldWidget.outerHTML : ''}</td>
<td>${providerWidget ? providerWidget.outerHTML : ''}</td>
<td>${priorityWidget ? priorityWidget.outerHTML : ''}</td>
<td>
<button type="button" class="btn btn-danger btn-sm" data-action="click->field-mapping#removeMapping">
<i class="fas fa-trash"></i>
</button>
</td>
`
this.tbodyTarget.appendChild(newRow)
this.mappingIndexValue++
const newFieldSelect = newRow.querySelector('select[name*="[field]"]')
if (newFieldSelect) {
newFieldSelect.value = ''
newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this))
}
this.updateFieldOptions()
this.updateAddButtonState()
}
removeMapping(event) {
const row = event.target.closest('tr')
row.remove()
this.updateFieldOptions()
this.updateAddButtonState()
}
updateFieldOptions() {
const fieldSelects = this.tbodyTarget.querySelectorAll('select[name*="[field]"]')
const selectedFields = Array.from(fieldSelects)
.map(select => select.value)
.filter(value => value && value !== '')
fieldSelects.forEach(select => {
Array.from(select.options).forEach(option => {
const isCurrentValue = option.value === select.value
const isEmptyOption = !option.value || option.value === ''
const isAlreadySelected = selectedFields.includes(option.value)
if (!isEmptyOption && isAlreadySelected && !isCurrentValue) {
option.disabled = true
option.style.display = 'none'
} else {
option.disabled = false
option.style.display = ''
}
})
})
}
updateAddButtonState() {
const currentMappings = this.tbodyTarget.querySelectorAll('.mapping-row').length
if (this.hasAddButtonTarget) {
if (currentMappings >= this.maxMappingsValue) {
this.addButtonTarget.disabled = true
this.addButtonTarget.title = this.maxMappingsReachedMessageValue
} else {
this.addButtonTarget.disabled = false
this.addButtonTarget.title = ''
}
}
}
handleFormSubmit(event) {
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = true
// Disable the entire form to prevent changes during processing
const form = event.target
const formElements = form.querySelectorAll('input, select, textarea, button')
formElements.forEach(element => {
if (element !== this.submitButtonTarget) {
element.disabled = true
}
})
}
}
}

View File

@@ -18,8 +18,8 @@
*/
.hoverpic {
min-width: 10px;
max-width: 30px;
min-width: var(--table-image-preview-min-size, 20px);
max-width: var(--table-image-preview-max-size, 35px);
display: block;
margin-left: auto;
margin-right: auto;
@@ -49,7 +49,7 @@
}
.part-table-image {
max-height: 40px;
max-height: calc(1.2*var(--table-image-preview-max-size, 35px)); /** Aspect ratio of maximum 1.2 */
object-fit: contain;
}

View File

@@ -17,6 +17,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/****************************************
* Action bar
****************************************/
.sticky-select-bar {
position: sticky;
top: 120px;
z-index: 1000; /* Ensure the bar is above other content */
}
/****************************************
* Tables
****************************************/
@@ -84,6 +94,11 @@ th.select-checkbox {
display: inline-flex;
}
/** Add spacing between column visibility button and length menu */
.buttons-colvis {
margin-right: 0.2em !important;
}
/** Fix datatables select-checkbox position */
table.dataTable tr.selected td.select-checkbox:after
{
@@ -109,4 +124,4 @@ Classes for Datatables export
#export-messageTop,
.export-helper{
display: none;
}
}

View File

@@ -71,6 +71,8 @@
--ck-color-button-on-hover-background: var(--bs-secondary-bg);
--ck-color-button-on-active-background: var(--bs-secondary-bg);
--ck-color-button-on-disabled-background: var(--bs-secondary-bg);
--ck-color-button-on-color: var(--bs-primary)
--ck-color-button-on-color: var(--bs-primary);
}
--ck-content-font-color: var(--ck-color-base-text);
}

View File

@@ -75,11 +75,10 @@
request._dt = config.name;
//Try to resolve the original column index when the column was reordered (using the ColReorder plugin)
//Only do this when _ColReorder_iOrigCol is available
if (settings.aoColumns && settings.aoColumns.length && settings.aoColumns[0]._ColReorder_iOrigCol !== undefined) {
if (dt.colReorder && dt.colReorder.transpose) {
if (request.order && request.order.length) {
request.order.forEach(function (order) {
order.column = settings.aoColumns[order.column]._ColReorder_iOrigCol;
order.column = dt.colReorder.transpose(order.column, "toOriginal");
});
}
}

View File

@@ -25,8 +25,6 @@
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^3.2.0",
"dompdf/dompdf": "^v3.0.0",
"florianv/swap": "^4.0",
"florianv/swap-bundle": "dev-master",
"gregwar/captcha-bundle": "^2.1.0",
"hshn/base64-encoded-file": "^5.0",
"jbtronics/2fa-webauthn": "^3.0.0",
@@ -38,6 +36,7 @@
"league/csv": "^9.8.0",
"league/html-to-markdown": "^5.0.1",
"liip/imagine-bundle": "^2.2",
"maennchen/zipstream-php": "2.1",
"nbgrp/onelogin-saml-bundle": "^v2.0.2",
"nelexa/zip": "^4.0",
"nelmio/cors-bundle": "^2.3",
@@ -46,6 +45,8 @@
"omines/datatables-bundle": "^0.10.0",
"paragonie/sodium_compat": "^1.21",
"part-db/label-fonts": "^1.0",
"part-db/swap-bundle": "^6.0.0",
"phpoffice/phpspreadsheet": "^5.0.0",
"rhukster/dom-sanitizer": "^1.0",
"runtime/frankenphp-symfony": "^0.2.0",
"s9e/text-formatter": "^2.1",
@@ -158,7 +159,7 @@
"post-update-cmd": [
"@auto-scripts"
],
"phpstan": "vendor/bin/phpstan analyse src --level 5 --memory-limit 1G"
"phpstan": "php -d memory_limit=1G vendor/bin/phpstan analyse src --level 5"
},
"conflict": {
"symfony/symfony": "*"

1544
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@ datatables:
>
<'row' <'col mt-2 input-group flex-nowrap' B l > <'col-auto mt-2' < p >>>"
pagingType: 'simple_numbers'
searching: true
searching: false
stateSave: true

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
/**
* This class extends the default doctrine ORM configuration to enable native lazy objects on PHP 8.4+.
* We have to do this in a PHP file, because the yaml file does not support conditionals on PHP version.
*/
return static function(\Symfony\Config\DoctrineConfig $doctrine) {
//On PHP 8.4+ we can use native lazy objects, which are much more efficient than proxies.
if (PHP_VERSION_ID >= 80400) {
$doctrine->orm()->enableNativeLazyObjects(true);
}
};

View File

@@ -69,6 +69,7 @@ when@docker:
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
include_stacktraces: true
channels: ["!deprecation"]
nested:
type: stream
path: "php://stderr"

View File

@@ -20,12 +20,6 @@ nelmio_security:
- 'digikey.com'
- 'nexar.com'
# forces Microsoft's XSS-Protection with
# its block mode
xss_protection:
enabled: true
mode_block: true
# Send a full URL in the `Referer` header when performing a same-origin request,
# only send the origin of the document to secure destination (HTTPS->HTTPS),
# and send no header to a less secure destination (HTTPS->HTTP).
@@ -69,9 +63,3 @@ nelmio_security:
- 'data:'
block-all-mixed-content: true # defaults to false, blocks HTTP content over HTTPS transport
# upgrade-insecure-requests: true # defaults to false, upgrades HTTP requests to HTTPS transport
when@dev:
# disables the Content-Security-Policy header
nelmio_security:
csp:
enabled: false

View File

@@ -5,4 +5,11 @@ jbtronics_settings:
default_cacheable: true
orm_storage:
default_entity_class: App\Entity\SettingsEntry
default_entity_class: App\Entity\SettingsEntry
# Disable caching for development environment
when@dev:
jbtronics_settings:
cache:
default_cacheable: false

View File

@@ -5,6 +5,12 @@ florianv_swap:
providers:
european_central_bank: ~ # European Central Bank (only works for EUR base currency)
fixer: # Fixer.io (needs an API key)
access_key: "%env(string:default:settings:exchange_rate:fixerApiKey:INVALID)%"
#exchange_rates_api: ~
central_bank_of_czech_republic: ~
central_bank_of_republic_turkey: ~
national_bank_of_romania: ~
fixer: # Fixer.io (needs an API key)
access_key: "%env(string:settings:exchange_rate:fixerApiKey)%"
frankfurter: ~
fawazahmed_currency_api: ~

View File

@@ -104,3 +104,9 @@ parameters:
env(SAML_ROLE_MAPPING): '{}'
env(DATABASE_EMULATE_NATURAL_SORT): 0
######################################################################################################################
# Bulk Info Provider Import Configuration
######################################################################################################################
partdb.bulk_import.batch_size: 20 # Number of parts to process in each batch during bulk operations
partdb.bulk_import.max_parts_per_operation: 1000 # Maximum number of parts allowed per bulk import operation

View File

@@ -359,6 +359,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
label: "perm.revert_elements"
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles', 'delete_profiles']
apiTokenRole: ROLE_API_EDIT
import:
label: "perm.import"
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles' ]
apiTokenRole: ROLE_API_EDIT
api:
label: "perm.api"

View File

@@ -1,4 +1,7 @@
name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;manufacturing_status
BC547;NPN transistor;Transistors -> NPN;very important notes;TO -> TO-92;NPN,Transistor;5;Room 1 -> Shelf 1 -> Box 2;10;;;Manufacturer;;You need to fill this line, to use spn and price;BC547C;2,3;0;;;;
BC557;PNP transistor;<b>HTML</b>;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active
Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter;
name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;eda_info.reference_prefix;eda_info.value;eda_info.visibility;eda_info.exclude_from_bom;eda_info.exclude_from_board;eda_info.exclude_from_sim;eda_info.kicad_symbol;eda_info.kicad_footprint
"MLCC; 0603; 0.22uF";Multilayer ceramic capacitor;Electrical Components->Passive Components->Capacitors_SMD;High quality MLCC;0603;Capacitor,SMD,MLCC,0603;500;Room 1->Shelf 1->Box 2;0.1;CL10B224KO8NNNC;CL10B224KO8NNNC;active;Samsung;LCSC;C160828;0.0023;0;0;1;pcs;C;0.22uF;1;0;0;0;Device:C;Capacitor_SMD:C_0603_1608Metric
"MLCC; 0402; 10pF";Small MLCC for high frequency;Electrical Components->Passive Components->Capacitors_SMD;;0402;Capacitor,SMD,MLCC,0402;500;Room 1->Shelf 1->Box 3;0.05;FCC0402N100J500AT;FCC0402N100J500AT;active;Fenghua;LCSC;C5137557;0.0015;0;0;1;pcs;C;10pF;1;0;0;0;Device:C;Capacitor_SMD:C_0402_1005Metric
"Diode; 1N4148W";Fast switching diode;Electrical Components->Semiconductors->Diodes;Fast recovery time;Diode_SMD:D_SOD-123;Diode,SMD,Schottky;100;Room 2->Box 1;0.2;1N4148W;1N4148W;active;Vishay;LCSC;C917030;0.008;0;0;1;pcs;D;1N4148W;1;0;0;0;Device:D;Diode_SMD:D_SOD-123
BC547;NPN transistor;Transistors->NPN;very important notes;TO->TO-92;NPN,Transistor;5;Room 1->Shelf 1->Box 2;10;BC547;BC547;active;Generic;LCSC;BC547C;2.3;0;0;1;pcs;Q;BC547;1;0;0;0;Device:Q_NPN_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder
BC557;PNP transistor;Transistors->PNP;PNP complement to BC547;TO->TO-92;PNP,Transistor;10;Room 2->Box 3;10;BC557;BC557;active;Generic;LCSC;BC557C;2.1;0;0;1;pcs;Q;BC557;1;0;0;0;Device:Q_PNP_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder
Copper Wire;Bare copper wire;Wire->Copper;For prototyping;Wire;Wire,Copper;50;Room 3->Spool Rack;0.5;CW-22AWG;CW-22AWG;active;Generic;Local Supplier;LS-CW-22;0.15;0;0;1;Meter;W;22AWG;1;0;0;0;Device:Wire;Connector_PinHeader_2.54mm:PinHeader_1x01_P2.54mm_Vertical
1 name description category notes footprint tags quantity storage_location mass ipn mpn manufacturing_status manufacturer supplier spn price favorite needs_review minamount partUnit manufacturing_status eda_info.reference_prefix eda_info.value eda_info.visibility eda_info.exclude_from_bom eda_info.exclude_from_board eda_info.exclude_from_sim eda_info.kicad_symbol eda_info.kicad_footprint
2 BC547 MLCC; 0603; 0.22uF NPN transistor Multilayer ceramic capacitor Transistors -> NPN Electrical Components->Passive Components->Capacitors_SMD very important notes High quality MLCC TO -> TO-92 0603 NPN,Transistor Capacitor,SMD,MLCC,0603 5 500 Room 1 -> Shelf 1 -> Box 2 Room 1->Shelf 1->Box 2 10 0.1 CL10B224KO8NNNC CL10B224KO8NNNC Manufacturer active Samsung You need to fill this line, to use spn and price LCSC BC547C C160828 2,3 0.0023 0 0 1 pcs C 0.22uF 1 0 0 0 Device:C Capacitor_SMD:C_0603_1608Metric
3 BC557 MLCC; 0402; 10pF PNP transistor Small MLCC for high frequency <b>HTML</b> Electrical Components->Passive Components->Capacitors_SMD TO -> TO-92 0402 PNP,Transistor Capacitor,SMD,MLCC,0402 10 500 Room 2-> Box 3 Room 1->Shelf 1->Box 3 0.05 Internal1234 FCC0402N100J500AT FCC0402N100J500AT active Fenghua LCSC C5137557 0.0015 0 1 0 1 pcs active C 10pF 1 0 0 0 Device:C Capacitor_SMD:C_0402_1005Metric
4 Copper Wire Diode; 1N4148W Fast switching diode Wire Electrical Components->Semiconductors->Diodes Fast recovery time Diode_SMD:D_SOD-123 Diode,SMD,Schottky 100 Room 2->Box 1 0.2 1N4148W 1N4148W active Vishay LCSC C917030 0.008 0 0 1 Meter pcs D 1N4148W 1 0 0 0 Device:D Diode_SMD:D_SOD-123
5 BC547 NPN transistor Transistors->NPN very important notes TO->TO-92 NPN,Transistor 5 Room 1->Shelf 1->Box 2 10 BC547 BC547 active Generic LCSC BC547C 2.3 0 0 1 pcs Q BC547 1 0 0 0 Device:Q_NPN_EBC TO_SOT_Packages_SMD:TO-92_HandSolder
6 BC557 PNP transistor Transistors->PNP PNP complement to BC547 TO->TO-92 PNP,Transistor 10 Room 2->Box 3 10 BC557 BC557 active Generic LCSC BC557C 2.1 0 0 1 pcs Q BC557 1 0 0 0 Device:Q_PNP_EBC TO_SOT_Packages_SMD:TO-92_HandSolder
7 Copper Wire Bare copper wire Wire->Copper For prototyping Wire Wire,Copper 50 Room 3->Spool Rack 0.5 CW-22AWG CW-22AWG active Generic Local Supplier LS-CW-22 0.15 0 0 1 Meter W 22AWG 1 0 0 0 Device:Wire Connector_PinHeader_2.54mm:PinHeader_1x01_P2.54mm_Vertical

View File

@@ -28,9 +28,14 @@ It is recommended to install Part-DB on a 64-bit system, as the 32-bit version o
For the installation of Part-DB, we need some prerequisites. They can be installed by running the following command:
```bash
sudo apt install git curl zip ca-certificates software-properties-common apt-transport-https lsb-release nano wget
sudo apt update && apt upgrade
sudo apt install git curl zip ca-certificates software-properties-common \
apt-transport-https lsb-release nano wget sqlite3
```
Please run `sqlite3 --version` to assert that the SQLite version is 3.35 or higher.
Otherwise some database migrations will not succeed.
### Install PHP and apache2
Part-DB is written in [PHP](https://php.net) and therefore needs a PHP interpreter to run. Part-DB needs PHP 8.2 or

View File

@@ -34,3 +34,12 @@ select the BOM file you want to import and some options for the import process:
has a different format and does not work with this type.
You can generate this BOM file by going to "File" -> "Fabrication Outputs" -> "Bill of Materials" in Pcbnew and save
the file to your desired location.
* **KiCAD Schematic BOM (CSV file)**: A CSV file of the Bill of Material (BOM) generated
by [KiCAD Eeschema](https://www.kicad.org/).
You can generate this BOM file by going to "Tools" -> "Generate Bill of Materials" in Eeschema and save the file to your
desired location. In the next step you can customize the mapping of the fields in Part-DB, if you have any special fields
in your BOM to locate your fields correctly.
* **Generic CSV file**: A generic CSV file. You can use this option if you use some different ECAD software or wanna create
your own CSV file. You will need to specify at least the designators, quantity and value fields in the CSV. In the next
step you can customize the mapping of the fields in Part-DB, if you have any special fields in your BOM to locate your
parts correctly.

View File

@@ -142,6 +142,9 @@ You can select between the following export formats:
efficiently.
* **YAML** (Yet Another Markup Language): Very similar to JSON
* **XML** (Extensible Markup Language): Good support with nested data structures. Similar use cases as JSON and YAML.
* **Excel**: Similar to CSV, but in a native Excel format. Can be opened in Excel and LibreOffice Calc. Does not support nested
data structures or sub-data (like parameters, attachments, etc.), very well (many columns are generated, as every
possible sub-data is exported as a separate column).
Also, you can select between the following export levels:

View File

@@ -68,6 +68,13 @@ If you already have attachment types for images and datasheets and want the info
can
add the alternative names "Datasheet" and "Image" to the alternative names field of the attachment types.
## Bulk import
If you want to update the information of multiple parts, you can use the bulk import system: Go to a part table and select
the parts you want to update. In the bulk actions dropdown select "Bulk info provider import" and click "Apply".
You will be redirected to a page, where you can select how part fields should be mapped to info provider fields, and the
results will be shown.
## Data providers
The system tries to be as flexible as possible, so many different information sources can be used.

91
makefile Normal file
View File

@@ -0,0 +1,91 @@
# PartDB Makefile for Test Environment Management
.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \
test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \
section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset
# Default target
help: ## Show this help
@awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
# Dependencies
deps-install: ## Install PHP dependencies with unlimited memory
@echo "📦 Installing PHP dependencies..."
COMPOSER_MEMORY_LIMIT=-1 composer install
yarn install
@echo "✅ Dependencies installed"
# Complete test environment setup
test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures)
@echo "✅ Test environment setup complete!"
# Clean test environment
test-clean: ## Clean test cache and database files
@echo "🧹 Cleaning test environment..."
rm -rf var/cache/test
rm -f var/app_test.db
@echo "✅ Test environment cleaned"
# Create test database
test-db-create: ## Create test database (if not exists)
@echo "🗄️ Creating test database..."
-php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
# Run database migrations for test environment
test-db-migrate: ## Run database migrations for test environment
@echo "🔄 Running database migrations..."
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test
# Clear test cache
test-cache-clear: ## Clear test cache
@echo "🗑️ Clearing test cache..."
rm -rf var/cache/test
@echo "✅ Test cache cleared"
# Load test fixtures
test-fixtures: ## Load test fixtures
@echo "📦 Loading test fixtures..."
php bin/console partdb:fixtures:load -n --env test
# Run PHPUnit tests
test-run: ## Run PHPUnit tests
@echo "🧪 Running tests..."
php bin/phpunit
# Quick test reset (clean + migrate + fixtures, skip DB creation)
test-reset: test-cache-clear test-db-migrate test-fixtures
@echo "✅ Test environment reset complete!"
test-typecheck: ## Run static analysis (PHPStan)
@echo "🧪 Running type checks..."
COMPOSER_MEMORY_LIMIT=-1 composer phpstan
# Development helpers
dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup)
@echo "✅ Development environment setup complete!"
dev-clean: ## Clean development cache and database files
@echo "🧹 Cleaning development environment..."
rm -rf var/cache/dev
rm -f var/app_dev.db
@echo "✅ Development environment cleaned"
dev-db-create: ## Create development database (if not exists)
@echo "🗄️ Creating development database..."
-php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
dev-db-migrate: ## Run database migrations for development environment
@echo "🔄 Running database migrations..."
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev
dev-cache-clear: ## Clear development cache
@echo "🗑️ Clearing development cache..."
rm -rf var/cache/dev
@echo "✅ Development cache cleared"
dev-warmup: ## Warm up development cache
@echo "🔥 Warming up development cache..."
COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n
dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate)
@echo "✅ Development environment reset complete!"

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250802205143 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add bulk info provider import jobs and job parts tables';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INT AUTO_INCREMENT NOT NULL, name LONGTEXT NOT NULL, field_mappings LONGTEXT NOT NULL, search_results LONGTEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details TINYINT(1) NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES `users` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INT AUTO_INCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason LONGTEXT DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id), CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES `parts` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name CLOB NOT NULL, field_mappings CLOB NOT NULL, search_results CLOB NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INTEGER NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason CLOB DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INTEGER NOT NULL, part_id INTEGER NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id SERIAL PRIMARY KEY NOT NULL, name TEXT NOT NULL, field_mappings TEXT NOT NULL, search_results TEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id SERIAL PRIMARY KEY NOT NULL, status VARCHAR(20) NOT NULL, reason TEXT DEFAULT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES parts (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
}
}

View File

@@ -4,7 +4,7 @@
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnDeprecation="false"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"

View File

@@ -24,6 +24,7 @@ namespace App\Controller;
use App\DataTables\AttachmentDataTable;
use App\DataTables\Filters\AttachmentFilter;
use App\DataTables\PartsDataTable;
use App\Entity\Attachments\Attachment;
use App\Form\Filters\AttachmentFilterType;
use App\Services\Attachments\AttachmentManager;
@@ -112,7 +113,7 @@ class AttachmentFileController extends AbstractController
$filterForm->handleRequest($formRequest);
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize])
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
->handleRequest($request);
if ($table->isCallback()) {

View File

@@ -0,0 +1,588 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Controller;
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use App\Entity\Parts\Part;
use App\Entity\Parts\Supplier;
use App\Entity\UserSystem\User;
use App\Form\InfoProviderSystem\GlobalFieldMappingType;
use App\Services\InfoProviderSystem\BulkInfoProviderService;
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/tools/bulk_info_provider_import')]
class BulkInfoProviderImportController extends AbstractController
{
public function __construct(
private readonly BulkInfoProviderService $bulkService,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
#[Autowire(param: 'partdb.bulk_import.batch_size')]
private readonly int $bulkImportBatchSize,
#[Autowire(param: 'partdb.bulk_import.max_parts_per_operation')]
private readonly int $bulkImportMaxParts
) {
}
/**
* Convert field mappings from array format to FieldMappingDTO[].
*
* @param array $fieldMappings Array of field mapping arrays
* @return BulkSearchFieldMappingDTO[] Array of FieldMappingDTO objects
*/
private function convertFieldMappingsToDto(array $fieldMappings): array
{
$dtos = [];
foreach ($fieldMappings as $mapping) {
$dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1);
}
return $dtos;
}
private function createErrorResponse(string $message, int $statusCode = 400, array $context = []): JsonResponse
{
$this->logger->warning('Bulk import operation failed', array_merge([
'error' => $message,
'user' => $this->getUser()?->getUserIdentifier(),
], $context));
return $this->json([
'success' => false,
'error' => $message
], $statusCode);
}
private function validateJobAccess(int $jobId): ?BulkInfoProviderImportJob
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
if (!$job) {
return null;
}
if ($job->getCreatedBy() !== $this->getUser()) {
return null;
}
return $job;
}
private function updatePartSearchResults(BulkInfoProviderImportJob $job, ?BulkSearchPartResultsDTO $newResults): void
{
if ($newResults === null) {
return;
}
// Only deserialize and update if we have new results
$allResults = $job->getSearchResults($this->entityManager);
// Find and update the results for this specific part
$allResults = $allResults->replaceResultsForPart($newResults);
// Save updated results back to job
$job->setSearchResults($allResults);
}
#[Route('/step1', name: 'bulk_info_provider_step1')]
public function step1(Request $request): Response
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
set_time_limit(600);
$ids = $request->query->get('ids');
if (!$ids) {
$this->addFlash('error', 'No parts selected for bulk import');
return $this->redirectToRoute('homepage');
}
$partIds = explode(',', $ids);
$partRepository = $this->entityManager->getRepository(Part::class);
$parts = $partRepository->getElementsFromIDArray($partIds);
if (empty($parts)) {
$this->addFlash('error', 'No valid parts found for bulk import');
return $this->redirectToRoute('homepage');
}
// Validate against configured maximum
if (count($parts) > $this->bulkImportMaxParts) {
$this->addFlash('error', sprintf(
'Too many parts selected (%d). Maximum allowed is %d parts per operation.',
count($parts),
$this->bulkImportMaxParts
));
return $this->redirectToRoute('homepage');
}
if (count($parts) > ($this->bulkImportMaxParts / 2)) {
$this->addFlash('warning', 'Processing ' . count($parts) . ' parts may take several minutes and could timeout. Consider processing smaller batches.');
}
// Generate field choices
$fieldChoices = [
'info_providers.bulk_search.field.mpn' => 'mpn',
'info_providers.bulk_search.field.name' => 'name',
];
// Add dynamic supplier fields
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierKey = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName()));
$fieldChoices["Supplier: " . $supplier->getName() . " (SPN)"] = $supplierKey . '_spn';
}
// Initialize form with useful default mappings
$initialData = [
'field_mappings' => [
['field' => 'mpn', 'providers' => [], 'priority' => 1]
],
'prefetch_details' => false
];
$form = $this->createForm(GlobalFieldMappingType::class, $initialData, [
'field_choices' => $fieldChoices
]);
$form->handleRequest($request);
$searchResults = null;
if ($form->isSubmitted() && $form->isValid()) {
$formData = $form->getData();
$fieldMappingDtos = $this->convertFieldMappingsToDto($formData['field_mappings']);
$prefetchDetails = $formData['prefetch_details'] ?? false;
$user = $this->getUser();
if (!$user instanceof User) {
throw new \RuntimeException('User must be authenticated and of type User');
}
// Validate part count against configuration limit
if (count($parts) > $this->bulkImportMaxParts) {
$this->addFlash('error', "Too many parts selected. Maximum allowed: {$this->bulkImportMaxParts}");
$partIds = array_map(fn($part) => $part->getId(), $parts);
return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]);
}
// Create and save the job
$job = new BulkInfoProviderImportJob();
$job->setFieldMappings($fieldMappingDtos);
$job->setPrefetchDetails($prefetchDetails);
$job->setCreatedBy($user);
foreach ($parts as $part) {
$jobPart = new BulkInfoProviderImportJobPart($job, $part);
$job->addJobPart($jobPart);
}
$this->entityManager->persist($job);
$this->entityManager->flush();
try {
$searchResultsDto = $this->bulkService->performBulkSearch($parts, $fieldMappingDtos, $prefetchDetails);
// Save search results to job
$job->setSearchResults($searchResultsDto);
$job->markAsInProgress();
$this->entityManager->flush();
// Prefetch details if requested
if ($prefetchDetails) {
$this->bulkService->prefetchDetailsForResults($searchResultsDto);
}
return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $job->getId()]);
} catch (\Exception $e) {
$this->logger->error('Critical error during bulk import search', [
'job_id' => $job->getId(),
'error' => $e->getMessage(),
'exception' => $e
]);
$this->entityManager->remove($job);
$this->entityManager->flush();
$this->addFlash('error', 'Search failed due to an error: ' . $e->getMessage());
$partIds = array_map(fn($part) => $part->getId(), $parts);
return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]);
}
}
// Get existing in-progress jobs for current user
$existingJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
->findBy(['createdBy' => $this->getUser(), 'status' => BulkImportJobStatus::IN_PROGRESS], ['createdAt' => 'DESC'], 10);
return $this->render('info_providers/bulk_import/step1.html.twig', [
'form' => $form,
'parts' => $parts,
'search_results' => $searchResults,
'existing_jobs' => $existingJobs,
'fieldChoices' => $fieldChoices
]);
}
#[Route('/manage', name: 'bulk_info_provider_manage')]
public function manageBulkJobs(): Response
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
// Get all jobs for current user
$allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
->findBy([], ['createdAt' => 'DESC']);
// Check and auto-complete jobs that should be completed
// Also clean up jobs with no results (failed searches)
$updatedJobs = false;
$jobsToDelete = [];
foreach ($allJobs as $job) {
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
$job->markAsCompleted();
$updatedJobs = true;
}
// Mark jobs with no results for deletion (failed searches)
if ($job->getResultCount() === 0 && $job->isInProgress()) {
$jobsToDelete[] = $job;
}
}
// Delete failed jobs
foreach ($jobsToDelete as $job) {
$this->entityManager->remove($job);
$updatedJobs = true;
}
// Flush changes if any jobs were updated
if ($updatedJobs) {
$this->entityManager->flush();
if (!empty($jobsToDelete)) {
$this->addFlash('info', 'Cleaned up ' . count($jobsToDelete) . ' failed job(s) with no results.');
}
}
return $this->render('info_providers/bulk_import/manage.html.twig', [
'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup
]);
}
#[Route('/job/{jobId}/delete', name: 'bulk_info_provider_delete', methods: ['DELETE'])]
public function deleteJob(int $jobId): Response
{
$job = $this->validateJobAccess($jobId);
if (!$job) {
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
}
// Only allow deletion of completed, failed, or stopped jobs
if (!$job->isCompleted() && !$job->isFailed() && !$job->isStopped()) {
return $this->json(['error' => 'Cannot delete active job'], 400);
}
$this->entityManager->remove($job);
$this->entityManager->flush();
return $this->json(['success' => true]);
}
#[Route('/job/{jobId}/stop', name: 'bulk_info_provider_stop', methods: ['POST'])]
public function stopJob(int $jobId): Response
{
$job = $this->validateJobAccess($jobId);
if (!$job) {
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
}
// Only allow stopping of pending or in-progress jobs
if (!$job->canBeStopped()) {
return $this->json(['error' => 'Cannot stop job in current status'], 400);
}
$job->markAsStopped();
$this->entityManager->flush();
return $this->json(['success' => true]);
}
#[Route('/step2/{jobId}', name: 'bulk_info_provider_step2')]
public function step2(int $jobId): Response
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
if (!$job) {
$this->addFlash('error', 'Bulk import job not found');
return $this->redirectToRoute('bulk_info_provider_step1');
}
// Check if user owns this job
if ($job->getCreatedBy() !== $this->getUser()) {
$this->addFlash('error', 'Access denied to this bulk import job');
return $this->redirectToRoute('bulk_info_provider_step1');
}
// Get the parts and deserialize search results
$parts = $job->getJobParts()->map(fn($jobPart) => $jobPart->getPart())->toArray();
$searchResults = $job->getSearchResults($this->entityManager);
return $this->render('info_providers/bulk_import/step2.html.twig', [
'job' => $job,
'parts' => $parts,
'search_results' => $searchResults,
]);
}
#[Route('/job/{jobId}/part/{partId}/mark-completed', name: 'bulk_info_provider_mark_completed', methods: ['POST'])]
public function markPartCompleted(int $jobId, int $partId): Response
{
$job = $this->validateJobAccess($jobId);
if (!$job) {
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
}
$job->markPartAsCompleted($partId);
// Auto-complete job if all parts are done
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
$job->markAsCompleted();
}
$this->entityManager->flush();
return $this->json([
'success' => true,
'progress' => $job->getProgressPercentage(),
'completed_count' => $job->getCompletedPartsCount(),
'total_count' => $job->getPartCount(),
'job_completed' => $job->isCompleted()
]);
}
#[Route('/job/{jobId}/part/{partId}/mark-skipped', name: 'bulk_info_provider_mark_skipped', methods: ['POST'])]
public function markPartSkipped(int $jobId, int $partId, Request $request): Response
{
$job = $this->validateJobAccess($jobId);
if (!$job) {
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
}
$reason = $request->request->get('reason', '');
$job->markPartAsSkipped($partId, $reason);
// Auto-complete job if all parts are done
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
$job->markAsCompleted();
}
$this->entityManager->flush();
return $this->json([
'success' => true,
'progress' => $job->getProgressPercentage(),
'completed_count' => $job->getCompletedPartsCount(),
'skipped_count' => $job->getSkippedPartsCount(),
'total_count' => $job->getPartCount(),
'job_completed' => $job->isCompleted()
]);
}
#[Route('/job/{jobId}/part/{partId}/mark-pending', name: 'bulk_info_provider_mark_pending', methods: ['POST'])]
public function markPartPending(int $jobId, int $partId): Response
{
$job = $this->validateJobAccess($jobId);
if (!$job) {
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
}
$job->markPartAsPending($partId);
$this->entityManager->flush();
return $this->json([
'success' => true,
'progress' => $job->getProgressPercentage(),
'completed_count' => $job->getCompletedPartsCount(),
'skipped_count' => $job->getSkippedPartsCount(),
'total_count' => $job->getPartCount(),
'job_completed' => $job->isCompleted()
]);
}
#[Route('/job/{jobId}/part/{partId}/research', name: 'bulk_info_provider_research_part', methods: ['POST'])]
public function researchPart(int $jobId, int $partId): JsonResponse
{
$job = $this->validateJobAccess($jobId);
if (!$job) {
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
}
$part = $this->entityManager->getRepository(Part::class)->find($partId);
if (!$part) {
return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]);
}
// Only refresh if the entity might be stale (optional optimization)
if ($this->entityManager->getUnitOfWork()->isScheduledForUpdate($part)) {
$this->entityManager->refresh($part);
}
try {
// Use the job's field mappings to perform the search
$fieldMappingDtos = $job->getFieldMappings();
$prefetchDetails = $job->isPrefetchDetails();
try {
$searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
} catch (\Exception $searchException) {
// Handle "no search results found" as a normal case, not an error
if (str_contains($searchException->getMessage(), 'No search results found')) {
$searchResultsDto = null;
} else {
throw $searchException;
}
}
// Update the job's search results for this specific part efficiently
$this->updatePartSearchResults($job, $searchResultsDto[0] ?? null);
// Prefetch details if requested
if ($prefetchDetails && $searchResultsDto !== null) {
$this->bulkService->prefetchDetailsForResults($searchResultsDto);
}
$this->entityManager->flush();
// Return the new results for this part
$newResults = $searchResultsDto[0] ?? null;
return $this->json([
'success' => true,
'part_id' => $partId,
'results_count' => $newResults ? $newResults->getResultCount() : 0,
'errors_count' => $newResults ? $newResults->getErrorCount() : 0,
'message' => 'Part research completed successfully'
]);
} catch (\Exception $e) {
return $this->createErrorResponse(
'Research failed: ' . $e->getMessage(),
500,
[
'job_id' => $jobId,
'part_id' => $partId,
'exception' => $e->getMessage()
]
);
}
}
#[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])]
public function researchAllParts(int $jobId): JsonResponse
{
$job = $this->validateJobAccess($jobId);
if (!$job) {
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
}
// Get all parts that are not completed or skipped
$parts = [];
foreach ($job->getJobParts() as $jobPart) {
if (!$jobPart->isCompleted() && !$jobPart->isSkipped()) {
$parts[] = $jobPart->getPart();
}
}
if (empty($parts)) {
return $this->json([
'success' => true,
'message' => 'No parts to research',
'researched_count' => 0
]);
}
try {
$fieldMappingDtos = $job->getFieldMappings();
$prefetchDetails = $job->isPrefetchDetails();
// Process in batches to reduce memory usage for large operations
$allResults = new BulkSearchResponseDTO(partResults: []);
$batches = array_chunk($parts, $this->bulkImportBatchSize);
foreach ($batches as $batch) {
$batchResultsDto = $this->bulkService->performBulkSearch($batch, $fieldMappingDtos, $prefetchDetails);
$allResults = BulkSearchResponseDTO::merge($allResults, $batchResultsDto);
// Properly manage entity manager memory without losing state
$jobId = $job->getId();
//$this->entityManager->clear(); //TODO: This seems to cause problems with the user relation, when trying to flush later
$job = $this->entityManager->find(BulkInfoProviderImportJob::class, $jobId);
}
// Update the job's search results
$job->setSearchResults($allResults);
// Prefetch details if requested
if ($prefetchDetails) {
$this->bulkService->prefetchDetailsForResults($allResults);
}
$this->entityManager->flush();
return $this->json([
'success' => true,
'researched_count' => count($parts),
'message' => sprintf('Successfully researched %d parts', count($parts))
]);
} catch (\Exception $e) {
return $this->createErrorResponse(
'Bulk research failed: ' . $e->getMessage(),
500,
[
'job_id' => $jobId,
'part_count' => count($parts),
'exception' => $e->getMessage()
]
);
}
}
}

View File

@@ -30,6 +30,7 @@ use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry;
use App\Settings\AppSettings;
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
use Doctrine\ORM\EntityManagerInterface;
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
@@ -113,7 +114,7 @@ class InfoProviderController extends AbstractController
#[Route('/search', name: 'info_providers_search')]
#[Route('/update/{target}', name: 'info_providers_update_part_search')]
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger): Response
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger, InfoProviderGeneralSettings $infoProviderSettings): Response
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
@@ -144,6 +145,23 @@ class InfoProviderController extends AbstractController
}
}
//If the providers form is still empty, use our default value from the settings
if (count($form->get('providers')->getData() ?? []) === 0) {
$default_providers = $infoProviderSettings->defaultSearchProviders;
$provider_objects = [];
foreach ($default_providers as $provider_key) {
try {
$tmp = $this->providerRegistry->getProviderByKey($provider_key);
if ($tmp->isActive()) {
$provider_objects[] = $tmp;
}
} catch (\InvalidArgumentException $e) {
//If the provider is not found, just ignore it
}
}
$form->get('providers')->setData($provider_objects);
}
if ($form->isSubmitted() && $form->isValid()) {
$keyword = $form->get('keyword')->getData();
$providers = $form->get('providers')->getData();

View File

@@ -58,12 +58,15 @@ use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/label')]
class LabelController extends AbstractController
{
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator)
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator,
private readonly ValidatorInterface $validator
)
{
}
@@ -85,6 +88,7 @@ class LabelController extends AbstractController
$form = $this->createForm(LabelDialogType::class, null, [
'disable_options' => $disable_options,
'profile' => $profile
]);
//Try to parse given target_type and target_id
@@ -120,13 +124,50 @@ class LabelController extends AbstractController
goto render;
}
$profile = new LabelProfile();
$profile->setName($form->get('save_profile_name')->getData());
$profile->setOptions($form_options);
$this->em->persist($profile);
$new_profile = new LabelProfile();
$new_profile->setName($form->get('save_profile_name')->getData());
$new_profile->setOptions($form_options);
//Validate the profile name
$errors = $this->validator->validate($new_profile);
if (count($errors) > 0) {
foreach ($errors as $error) {
$form->get('save_profile_name')->addError(new FormError($error->getMessage()));
}
goto render;
}
$this->em->persist($new_profile);
$this->em->flush();
$this->addFlash('success', 'label_generator.profile_saved');
return $this->redirectToRoute('label_dialog_profile', [
'profile' => $new_profile->getID(),
'target_id' => (string) $form->get('target_id')->getData()
]);
}
//Check if the current profile should be updated
if ($form->has('update_profile')
&& $form->get('update_profile')->isClicked() //@phpstan-ignore-line Phpstan does not recognize the isClicked method
&& $profile instanceof LabelProfile
&& $this->isGranted('edit', $profile)) {
//Update the profile options
$profile->setOptions($form_options);
//Validate the profile name
$errors = $this->validator->validate($profile);
if (count($errors) > 0) {
foreach ($errors as $error) {
$this->addFlash('error', $error->getMessage());
}
goto render;
}
$this->em->persist($profile);
$this->em->flush();
$this->addFlash('success', 'label_generator.profile_updated');
return $this->redirectToRoute('label_dialog_profile', [
'profile' => $profile->getID(),
'target_id' => (string) $form->get('target_id')->getData()

View File

@@ -46,6 +46,7 @@ use App\Services\Parameters\ParameterExtractor;
use App\Services\Parts\PartLotWithdrawAddHelper;
use App\Services\Parts\PricedetailHelper;
use App\Services\ProjectSystem\ProjectBuildPartHelper;
use App\Settings\BehaviorSettings\PartInfoSettings;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@@ -63,14 +64,17 @@ use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
#[Route(path: '/part')]
class PartController extends AbstractController
final class PartController extends AbstractController
{
public function __construct(protected PricedetailHelper $pricedetailHelper,
protected PartPreviewGenerator $partPreviewGenerator,
public function __construct(
private readonly PricedetailHelper $pricedetailHelper,
private readonly PartPreviewGenerator $partPreviewGenerator,
private readonly TranslatorInterface $translator,
private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em,
protected EventCommentHelper $commentHelper)
{
private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
private readonly EntityManagerInterface $em,
private readonly EventCommentHelper $commentHelper,
private readonly PartInfoSettings $partInfoSettings,
) {
}
/**
@@ -79,9 +83,16 @@ class PartController extends AbstractController
*/
#[Route(path: '/{id}/info/{timestamp}', name: 'part_info')]
#[Route(path: '/{id}', requirements: ['id' => '\d+'])]
public function show(Part $part, Request $request, TimeTravel $timeTravel, HistoryHelper $historyHelper,
DataTableFactory $dataTable, ParameterExtractor $parameterExtractor, PartLotWithdrawAddHelper $withdrawAddHelper, ?string $timestamp = null): Response
{
public function show(
Part $part,
Request $request,
TimeTravel $timeTravel,
HistoryHelper $historyHelper,
DataTableFactory $dataTable,
ParameterExtractor $parameterExtractor,
PartLotWithdrawAddHelper $withdrawAddHelper,
?string $timestamp = null
): Response {
$this->denyAccessUnlessGranted('read', $part);
$timeTravel_timestamp = null;
@@ -119,8 +130,8 @@ class PartController extends AbstractController
'pricedetail_helper' => $this->pricedetailHelper,
'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part),
'timeTravel' => $timeTravel_timestamp,
'description_params' => $parameterExtractor->extractParameters($part->getDescription()),
'comment_params' => $parameterExtractor->extractParameters($part->getComment()),
'description_params' => $this->partInfoSettings->extractParamsFromDescription ? $parameterExtractor->extractParameters($part->getDescription()) : [],
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
'withdraw_add_helper' => $withdrawAddHelper,
]
);
@@ -131,7 +142,43 @@ class PartController extends AbstractController
{
$this->denyAccessUnlessGranted('edit', $part);
return $this->renderPartForm('edit', $request, $part);
// Check if this is part of a bulk import job
$jobId = $request->query->get('jobId');
$bulkJob = null;
if ($jobId) {
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
// Verify user owns this job
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
$bulkJob = null;
}
}
return $this->renderPartForm('edit', $request, $part, [], [
'bulk_job' => $bulkJob
]);
}
#[Route(path: '/{id}/bulk-import-complete/{jobId}', name: 'part_bulk_import_complete', methods: ['POST'])]
public function markBulkImportComplete(Part $part, int $jobId, Request $request): Response
{
$this->denyAccessUnlessGranted('edit', $part);
if (!$this->isCsrfTokenValid('bulk_complete_' . $part->getId(), $request->request->get('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token');
}
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) {
throw $this->createNotFoundException('Bulk import job not found');
}
$bulkJob->markPartAsCompleted($part->getId());
$this->em->persist($bulkJob);
$this->em->flush();
$this->addFlash('success', 'Part marked as completed in bulk import');
return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $jobId]);
}
#[Route(path: '/{id}/delete', name: 'part_delete', methods: ['DELETE'])]
@@ -139,7 +186,7 @@ class PartController extends AbstractController
{
$this->denyAccessUnlessGranted('delete', $part);
if ($this->isCsrfTokenValid('delete'.$part->getID(), $request->request->get('_token'))) {
if ($this->isCsrfTokenValid('delete' . $part->getID(), $request->request->get('_token'))) {
$this->commentHelper->setMessage($request->request->get('log_comment', null));
@@ -158,11 +205,15 @@ class PartController extends AbstractController
#[Route(path: '/new', name: 'part_new')]
#[Route(path: '/{id}/clone', name: 'part_clone')]
#[Route(path: '/new_build_part/{project_id}', name: 'part_new_build_part')]
public function new(Request $request, EntityManagerInterface $em, TranslatorInterface $translator,
AttachmentSubmitHandler $attachmentSubmitHandler, ProjectBuildPartHelper $projectBuildPartHelper,
public function new(
Request $request,
EntityManagerInterface $em,
TranslatorInterface $translator,
AttachmentSubmitHandler $attachmentSubmitHandler,
ProjectBuildPartHelper $projectBuildPartHelper,
#[MapEntity(mapping: ['id' => 'id'])] ?Part $part = null,
#[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null): Response
{
#[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null
): Response {
if ($part instanceof Part) {
//Clone part
@@ -257,9 +308,14 @@ class PartController extends AbstractController
}
#[Route(path: '/{id}/from_info_provider/{providerKey}/{providerId}/update', name: 'info_providers_update_part', requirements: ['providerId' => '.+'])]
public function updateFromInfoProvider(Part $part, Request $request, string $providerKey, string $providerId,
PartInfoRetriever $infoRetriever, PartMerger $partMerger): Response
{
public function updateFromInfoProvider(
Part $part,
Request $request,
string $providerKey,
string $providerId,
PartInfoRetriever $infoRetriever,
PartMerger $partMerger
): Response {
$this->denyAccessUnlessGranted('edit', $part);
$this->denyAccessUnlessGranted('@info_providers.create_parts');
@@ -273,10 +329,22 @@ class PartController extends AbstractController
$this->addFlash('notice', t('part.merge.flash.please_review'));
// Check if this is part of a bulk import job
$jobId = $request->query->get('jobId');
$bulkJob = null;
if ($jobId) {
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
// Verify user owns this job
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
$bulkJob = null;
}
}
return $this->renderPartForm('update_from_ip', $request, $part, [
'info_provider_dto' => $dto,
], [
'tname_before' => $old_name
'tname_before' => $old_name,
'bulk_job' => $bulkJob
]);
}
@@ -311,7 +379,7 @@ class PartController extends AbstractController
} catch (AttachmentDownloadException $attachmentDownloadException) {
$this->addFlash(
'error',
$this->translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage()
$this->translator->trans('attachment.download_failed') . ' ' . $attachmentDownloadException->getMessage()
);
}
}
@@ -352,6 +420,12 @@ class PartController extends AbstractController
return $this->redirectToRoute('part_new');
}
// Check if we're in bulk import mode and preserve jobId
$jobId = $request->query->get('jobId');
if ($jobId && isset($merge_infos['bulk_job'])) {
return $this->redirectToRoute('part_edit', ['id' => $new_part->getID(), 'jobId' => $jobId]);
}
return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]);
}
@@ -370,13 +444,17 @@ class PartController extends AbstractController
$template = 'parts/edit/update_from_ip.html.twig';
}
return $this->render($template,
return $this->render(
$template,
[
'part' => $new_part,
'form' => $form,
'merge_old_name' => $merge_infos['tname_before'] ?? null,
'merge_other' => $merge_infos['other_part'] ?? null
]);
'merge_other' => $merge_infos['other_part'] ?? null,
'bulk_job' => $merge_infos['bulk_job'] ?? null,
'jobId' => $request->query->get('jobId')
]
);
}
@@ -386,17 +464,17 @@ class PartController extends AbstractController
if ($this->isCsrfTokenValid('part_withraw' . $part->getID(), $request->request->get('_csfr'))) {
//Retrieve partlot from the request
$partLot = $em->find(PartLot::class, $request->request->get('lot_id'));
if(!$partLot instanceof PartLot) {
if (!$partLot instanceof PartLot) {
throw new \RuntimeException('Part lot not found!');
}
//Ensure that the partlot belongs to the part
if($partLot->getPart() !== $part) {
if ($partLot->getPart() !== $part) {
throw new \RuntimeException("The origin partlot does not belong to the part!");
}
//Try to determine the target lot (used for move actions), if the parameter is existing
$targetId = $request->request->get('target_id', null);
$targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null;
$targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null;
if ($targetLot && $targetLot->getPart() !== $part) {
throw new \RuntimeException("The target partlot does not belong to the part!");
}
@@ -410,12 +488,12 @@ class PartController extends AbstractController
$timestamp = null;
$timestamp_str = $request->request->getString('timestamp', '');
//Try to parse the timestamp
if($timestamp_str !== '') {
if ($timestamp_str !== '') {
$timestamp = new DateTime($timestamp_str);
}
//Ensure that the timestamp is not in the future
if($timestamp !== null && $timestamp > new DateTime("+20min")) {
if ($timestamp !== null && $timestamp > new DateTime("+20min")) {
throw new \LogicException("The timestamp must not be in the future!");
}
@@ -459,7 +537,7 @@ class PartController extends AbstractController
err:
//If a redirect was passed, then redirect there
if($request->request->get('_redirect')) {
if ($request->request->get('_redirect')) {
return $this->redirect($request->request->get('_redirect'));
}
//Otherwise just redirect to the part page

View File

@@ -161,7 +161,9 @@ class PartListsController extends AbstractController
$filterForm->handleRequest($formRequest);
$table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(['filter' => $filter], $additional_table_vars), ['pageLength' => $this->tableSettings->fullDefaultPageSize])
$table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(
['filter' => $filter], $additional_table_vars),
['pageLength' => $this->tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
->handleRequest($request);
if ($table->isCallback()) {

View File

@@ -36,6 +36,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use League\Csv\SyntaxError;
use Omines\DataTablesBundle\DataTableFactory;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@@ -102,9 +103,14 @@ class ProjectController extends AbstractController
$this->addFlash('success', 'project.build.flash.success');
return $this->redirect(
$request->get('_redirect',
$this->generateUrl('project_info', ['id' => $project->getID()]
)));
$request->get(
'_redirect',
$this->generateUrl(
'project_info',
['id' => $project->getID()]
)
)
);
}
$this->addFlash('error', 'project.build.flash.invalid_input');
@@ -120,9 +126,13 @@ class ProjectController extends AbstractController
}
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project,
BOMImporter $BOMImporter, ValidatorInterface $validator): Response
{
public function importBOM(
Request $request,
EntityManagerInterface $entityManager,
Project $project,
BOMImporter $BOMImporter,
ValidatorInterface $validator
): Response {
$this->denyAccessUnlessGranted('edit', $project);
$builder = $this->createFormBuilder();
@@ -138,6 +148,8 @@ class ProjectController extends AbstractController
'required' => true,
'choices' => [
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
'project.bom_import.type.kicad_schematic' => 'kicad_schematic',
'project.bom_import.type.generic_csv' => 'generic_csv',
]
]);
$builder->add('clear_existing_bom', CheckboxType::class, [
@@ -161,25 +173,40 @@ class ProjectController extends AbstractController
$entityManager->flush();
}
$import_type = $form->get('type')->getData();
try {
// For schematic imports, redirect to field mapping step
if (in_array($import_type, ['kicad_schematic', 'generic_csv'], true)) {
// Store file content and options in session for field mapping step
$file_content = $form->get('file')->getData()->getContent();
$clear_existing = $form->get('clear_existing_bom')->getData();
$request->getSession()->set('bom_import_data', $file_content);
$request->getSession()->set('bom_import_clear', $clear_existing);
return $this->redirectToRoute('project_import_bom_map_fields', ['id' => $project->getID()]);
}
// For PCB imports, proceed directly
$entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
'type' => $form->get('type')->getData(),
'type' => $import_type,
]);
//Validate the project entries
// Validate the project entries
$errors = $validator->validateProperty($project, 'bom_entries');
//If no validation errors occured, save the changes and redirect to edit page
if (count ($errors) === 0) {
// If no validation errors occurred, save the changes and redirect to edit page
if (count($errors) === 0) {
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
}
//When we get here, there were validation errors
// When we get here, there were validation errors
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
} catch (\UnexpectedValueException|SyntaxError $e) {
} catch (\UnexpectedValueException | SyntaxError $e) {
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
}
}
@@ -191,11 +218,267 @@ class ProjectController extends AbstractController
]);
}
#[Route(path: '/{id}/import_bom/map_fields', name: 'project_import_bom_map_fields', requirements: ['id' => '\d+'])]
public function importBOMMapFields(
Request $request,
EntityManagerInterface $entityManager,
Project $project,
BOMImporter $BOMImporter,
ValidatorInterface $validator,
LoggerInterface $logger
): Response {
$this->denyAccessUnlessGranted('edit', $project);
// Get stored data from session
$file_content = $request->getSession()->get('bom_import_data');
$clear_existing = $request->getSession()->get('bom_import_clear', false);
if (!$file_content) {
$this->addFlash('error', 'project.bom_import.flash.session_expired');
return $this->redirectToRoute('project_import_bom', ['id' => $project->getID()]);
}
// Detect fields and get suggestions
$detected_fields = $BOMImporter->detectFields($file_content);
$suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields);
// Create mapping of original field names to sanitized field names for template
$field_name_mapping = [];
foreach ($detected_fields as $field) {
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$field_name_mapping[$field] = $sanitized_field;
}
// Create form for field mapping
$builder = $this->createFormBuilder();
// Add delimiter selection
$builder->add('delimiter', ChoiceType::class, [
'label' => 'project.bom_import.delimiter',
'required' => true,
'data' => ',',
'choices' => [
'project.bom_import.delimiter.comma' => ',',
'project.bom_import.delimiter.semicolon' => ';',
'project.bom_import.delimiter.tab' => "\t",
]
]);
// Get dynamic field mapping targets from BOMImporter
$available_targets = $BOMImporter->getAvailableFieldTargets();
$target_fields = ['project.bom_import.field_mapping.ignore' => ''];
foreach ($available_targets as $target_key => $target_info) {
$target_fields[$target_info['label']] = $target_key;
}
foreach ($detected_fields as $field) {
// Sanitize field name for form use - replace invalid characters with underscores
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$builder->add('mapping_' . $sanitized_field, ChoiceType::class, [
'label' => $field,
'required' => false,
'choices' => $target_fields,
'data' => $suggested_mapping[$field] ?? '',
]);
}
$builder->add('submit', SubmitType::class, [
'label' => 'project.bom_import.preview',
]);
$form = $builder->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Build field mapping array with priority support
$field_mapping = [];
$field_priorities = [];
$delimiter = $form->get('delimiter')->getData();
foreach ($detected_fields as $field) {
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$target = $form->get('mapping_' . $sanitized_field)->getData();
if (!empty($target)) {
$field_mapping[$field] = $target;
// Get priority from request (default to 10)
$priority = $request->request->get('priority_' . $sanitized_field, 10);
$field_priorities[$field] = (int) $priority;
}
}
// Validate field mapping
$validation = $BOMImporter->validateFieldMapping($field_mapping, $detected_fields);
if (!$validation['is_valid']) {
foreach ($validation['errors'] as $error) {
$this->addFlash('error', $error);
}
foreach ($validation['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form->createView(),
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
]);
}
// Show warnings but continue
foreach ($validation['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
try {
// Re-detect fields with chosen delimiter
$detected_fields = $BOMImporter->detectFields($file_content, $delimiter);
// Clear existing BOM entries if requested
if ($clear_existing) {
$existing_count = $project->getBomEntries()->count();
$logger->info('Clearing existing BOM entries', [
'existing_count' => $existing_count,
'project_id' => $project->getID(),
]);
$project->getBomEntries()->clear();
$entityManager->flush();
$logger->info('Existing BOM entries cleared');
} else {
$existing_count = $project->getBomEntries()->count();
$logger->info('Keeping existing BOM entries', [
'existing_count' => $existing_count,
'project_id' => $project->getID(),
]);
}
// Validate data before importing
$validation_result = $BOMImporter->validateBOMData($file_content, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,
'delimiter' => $delimiter,
]);
// Log validation results
$logger->info('BOM import validation completed', [
'total_entries' => $validation_result['total_entries'],
'valid_entries' => $validation_result['valid_entries'],
'invalid_entries' => $validation_result['invalid_entries'],
'error_count' => count($validation_result['errors']),
'warning_count' => count($validation_result['warnings']),
]);
// Show validation warnings to user
foreach ($validation_result['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
// If there are validation errors, show them and stop
if (!empty($validation_result['errors'])) {
foreach ($validation_result['errors'] as $error) {
$this->addFlash('error', $error);
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form->createView(),
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
'validation_result' => $validation_result,
]);
}
// Import with field mapping and priorities (validation already passed)
$entries = $BOMImporter->stringToBOMEntries($file_content, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,
'delimiter' => $delimiter,
]);
// Log entry details for debugging
$logger->info('BOM entries created', [
'total_entries' => count($entries),
]);
foreach ($entries as $index => $entry) {
$logger->debug("BOM entry {$index}", [
'name' => $entry->getName(),
'mountnames' => $entry->getMountnames(),
'quantity' => $entry->getQuantity(),
'comment' => $entry->getComment(),
'part_id' => $entry->getPart()?->getID(),
]);
}
// Assign entries to project
$logger->info('Adding BOM entries to project', [
'entries_count' => count($entries),
'project_id' => $project->getID(),
]);
foreach ($entries as $index => $entry) {
$logger->debug("Adding BOM entry {$index} to project", [
'name' => $entry->getName(),
'part_id' => $entry->getPart()?->getID(),
'quantity' => $entry->getQuantity(),
]);
$project->addBomEntry($entry);
}
// Validate the project entries (includes collection constraints)
$errors = $validator->validateProperty($project, 'bom_entries');
// If no validation errors occurred, save and redirect
if (count($errors) === 0) {
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
// Clear session data
$request->getSession()->remove('bom_import_data');
$request->getSession()->remove('bom_import_clear');
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
}
// When we get here, there were validation errors
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
//Print validation errors to log for debugging
foreach ($errors as $error) {
$logger->error('BOM entry validation error', [
'message' => $error->getMessage(),
'invalid_value' => $error->getInvalidValue(),
]);
//And show as flash message
$this->addFlash('error', $error->getMessage(),);
}
} catch (\UnexpectedValueException | SyntaxError $e) {
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
}
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form,
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
]);
}
#[Route(path: '/add_parts', name: 'project_add_parts_no_id')]
#[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])]
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response
{
if($project instanceof Project) {
if ($project instanceof Project) {
$this->denyAccessUnlessGranted('edit', $project);
} else {
$this->denyAccessUnlessGranted('@projects.edit');
@@ -242,7 +525,7 @@ class ProjectController extends AbstractController
$data = $form->getData();
$bom_entries = $data['bom_entries'];
foreach ($bom_entries as $bom_entry){
foreach ($bom_entries as $bom_entry) {
$target_project->addBOMEntry($bom_entry);
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\PriceInformations\Currency;
use Brick\Math\BigDecimal;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class CurrencyFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$currency1 = new Currency();
$currency1->setName('US-Dollar');
$currency1->setIsoCode('USD');
$manager->persist($currency1);
$currency2 = new Currency();
$currency2->setName('Swiss Franc');
$currency2->setIsoCode('CHF');
$currency2->setExchangeRate(BigDecimal::of('0.91'));
$manager->persist($currency2);
$currency3 = new Currency();
$currency3->setName('Great British Pound');
$currency3->setIsoCode('GBP');
$currency3->setExchangeRate(BigDecimal::of('0.78'));
$manager->persist($currency3);
$currency7 = new Currency();
$currency7->setName('Test Currency with long name');
$currency7->setIsoCode('CNY');
$manager->persist($currency7);
$manager->flush();
//Ensure that currency 7 gets ID 7
$manager->getRepository(Currency::class)->changeID($currency7, 7);
$manager->flush();
}
}

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
@@ -32,6 +33,7 @@ use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\Attachments\AttachmentType;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Filter\AbstractFilter;
class AttachmentFilter implements FilterInterface
{
@@ -51,6 +53,9 @@ class AttachmentFilter implements FilterInterface
public function __construct(NodesListBuilder $nodesListBuilder)
{
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
AbstractConstraint::resetParameterCounter();
$this->dbId = new IntConstraint('attachment.id');
$this->name = new TextConstraint('attachment.name');
$this->targetType = new InstanceOfConstraint('attachment');

View File

@@ -28,10 +28,7 @@ abstract class AbstractConstraint implements FilterInterface
{
use FilterTrait;
/**
* @var string
*/
protected string $identifier;
protected ?string $identifier;
/**

View File

@@ -28,6 +28,7 @@ trait FilterTrait
{
protected bool $useHaving = false;
protected static int $parameterCounter = 0;
public function useHaving($value = true): static
{
@@ -50,8 +51,18 @@ trait FilterTrait
{
//Replace all special characters with underscores
$property = preg_replace('/\W/', '_', $property);
//Add a random number to the end of the property name for uniqueness
return $property . '_' . uniqid("", false);
return $property . '_' . (self::$parameterCounter++) . '_';
}
/**
* Resets the parameter counter, so the next call to generateParameterIdentifier will start from 0 again.
* This should be done before initializing a new set of filters to a fresh query builder, to ensure that the parameter
* identifiers are deterministic so that they are cacheable.
* @return void
*/
public static function resetParameterCounter(): void
{
self::$parameterCounter = 0;
}
/**

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use Doctrine\ORM\QueryBuilder;
class BulkImportJobExistsConstraint extends BooleanConstraint
{
public function __construct()
{
parent::__construct('bulk_import_job_exists');
}
public function apply(QueryBuilder $queryBuilder): void
{
// Do not apply a filter if value is null (filter is set to ignore)
if (!$this->isEnabled()) {
return;
}
// Use EXISTS subquery to avoid join conflicts
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
$existsSubquery->select('1')
->from(BulkInfoProviderImportJobPart::class, 'bip_exists')
->where('bip_exists.part = part.id');
if ($this->value === true) {
// Filter for parts that ARE in bulk import jobs
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
} else {
// Filter for parts that are NOT in bulk import jobs
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use Doctrine\ORM\QueryBuilder;
class BulkImportJobStatusConstraint extends ChoiceConstraint
{
public function __construct()
{
parent::__construct('bulk_import_job_status');
}
public function apply(QueryBuilder $queryBuilder): void
{
// Do not apply a filter if values are empty or operator is null
if (!$this->isEnabled()) {
return;
}
// Use EXISTS subquery to check if part has a job with the specified status(es)
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
$existsSubquery->select('1')
->from(BulkInfoProviderImportJobPart::class, 'bip_status')
->join('bip_status.job', 'job_status')
->where('bip_status.part = part.id');
// Add status conditions based on operator
if ($this->operator === 'ANY') {
$existsSubquery->andWhere('job_status.status IN (:job_status_values)');
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
$queryBuilder->setParameter('job_status_values', $this->value);
} elseif ($this->operator === 'NONE') {
$existsSubquery->andWhere('job_status.status IN (:job_status_values)');
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
$queryBuilder->setParameter('job_status_values', $this->value);
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use Doctrine\ORM\QueryBuilder;
class BulkImportPartStatusConstraint extends ChoiceConstraint
{
public function __construct()
{
parent::__construct('bulk_import_part_status');
}
public function apply(QueryBuilder $queryBuilder): void
{
// Do not apply a filter if values are empty or operator is null
if (!$this->isEnabled()) {
return;
}
// Use EXISTS subquery to check if part has the specified status(es)
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
$existsSubquery->select('1')
->from(BulkInfoProviderImportJobPart::class, 'bip_part_status')
->where('bip_part_status.part = part.id');
// Add status conditions based on operator
if ($this->operator === 'ANY') {
$existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
$queryBuilder->setParameter('part_status_values', $this->value);
} elseif ($this->operator === 'NONE') {
$existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
$queryBuilder->setParameter('part_status_values', $this->value);
}
}
}

View File

@@ -88,7 +88,7 @@ class TagsConstraint extends AbstractConstraint
//Escape any %, _ or \ in the tag
$tag = addcslashes($tag, '%_\\');
$tag_identifier_prefix = uniqid($this->identifier . '_', false);
$tag_identifier_prefix = $this->generateParameterIdentifier('tag');
$expr = $queryBuilder->expr();

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
@@ -44,6 +45,9 @@ class LogFilter implements FilterInterface
public function __construct()
{
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
AbstractConstraint::resetParameterCounter();
$this->timestamp = new DateTimeConstraint('log.timestamp');
$this->dbId = new IntConstraint('log.id');
$this->level = new ChoiceConstraint('log.level');

View File

@@ -22,12 +22,16 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
use App\DataTables\Filters\Constraints\IntConstraint;
use App\DataTables\Filters\Constraints\NumberConstraint;
use App\DataTables\Filters\Constraints\Part\BulkImportJobExistsConstraint;
use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint;
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
use App\DataTables\Filters\Constraints\Part\LessThanDesiredConstraint;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
@@ -101,8 +105,19 @@ class PartFilter implements FilterInterface
public readonly TextConstraint $bomName;
public readonly TextConstraint $bomComment;
/*************************************************
* Bulk Import Job tab
*************************************************/
public readonly BulkImportJobExistsConstraint $inBulkImportJob;
public readonly BulkImportJobStatusConstraint $bulkImportJobStatus;
public readonly BulkImportPartStatusConstraint $bulkImportPartStatus;
public function __construct(NodesListBuilder $nodesListBuilder)
{
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
AbstractConstraint::resetParameterCounter();
$this->name = new TextConstraint('part.name');
$this->description = new TextConstraint('part.description');
$this->comment = new TextConstraint('part.comment');
@@ -126,7 +141,7 @@ class PartFilter implements FilterInterface
*/
$this->amountSum = (new IntConstraint('(
SELECT COALESCE(SUM(__partLot.amount), 0.0)
FROM '.PartLot::class.' __partLot
FROM ' . PartLot::class . ' __partLot
WHERE __partLot.part = part.id
AND __partLot.instock_unknown = false
AND (__partLot.expiration_date IS NULL OR __partLot.expiration_date > CURRENT_DATE())
@@ -162,6 +177,11 @@ class PartFilter implements FilterInterface
$this->bomName = new TextConstraint('_projectBomEntries.name');
$this->bomComment = new TextConstraint('_projectBomEntries.comment');
// Bulk Import Job filters
$this->inBulkImportJob = new BulkImportJobExistsConstraint();
$this->bulkImportJobStatus = new BulkImportJobStatusConstraint();
$this->bulkImportPartStatus = new BulkImportPartStatusConstraint();
}
public function apply(QueryBuilder $queryBuilder): void

View File

@@ -21,6 +21,7 @@ declare(strict_types=1);
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use Doctrine\ORM\QueryBuilder;
class PartSearchFilter implements FilterInterface

View File

@@ -142,23 +142,25 @@ final class PartsDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.storeLocations'),
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
'orderField' => 'NATSORT(MIN(_storelocations.name))',
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
], alias: 'storage_location')
->add('amount', TextColumn::class, [
'label' => $this->translator->trans('part.table.amount'),
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
'orderField' => 'amountSum'
])
->add('minamount', TextColumn::class, [
'label' => $this->translator->trans('part.table.minamount'),
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value,
$context->getPartUnit())),
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format(
$value,
$context->getPartUnit()
)),
])
->add('partUnit', TextColumn::class, [
'label' => $this->translator->trans('part.table.partUnit'),
'orderField' => 'NATSORT(_partUnit.name)',
'render' => function($value, Part $context): string {
'render' => function ($value, Part $context): string {
$partUnit = $context->getPartUnit();
if ($partUnit === null) {
return '';
@@ -167,7 +169,7 @@ final class PartsDataTable implements DataTableTypeInterface
$tmp = htmlspecialchars($partUnit->getName());
if ($partUnit->getUnit()) {
$tmp .= ' ('.htmlspecialchars($partUnit->getUnit()).')';
$tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')';
}
return $tmp;
}
@@ -230,7 +232,7 @@ final class PartsDataTable implements DataTableTypeInterface
}
if (count($projects) > $max) {
$tmp .= ", + ".(count($projects) - $max);
$tmp .= ", + " . (count($projects) - $max);
}
return $tmp;
@@ -366,7 +368,7 @@ final class PartsDataTable implements DataTableTypeInterface
$builder->addSelect(
'(
SELECT COALESCE(SUM(partLot.amount), 0.0)
FROM '.PartLot::class.' partLot
FROM ' . PartLot::class . ' partLot
WHERE partLot.part = part.id
AND partLot.instock_unknown = false
AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE())
@@ -423,6 +425,13 @@ final class PartsDataTable implements DataTableTypeInterface
//Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
//$builder->addGroupBy('_projectBomEntries');
}
if (str_contains($dql, '_jobPart')) {
$builder->leftJoin('part.bulkImportJobParts', '_jobPart');
$builder->leftJoin('_jobPart.job', '_bulkImportJob');
//Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
//$builder->addGroupBy('_jobPart');
//$builder->addGroupBy('_bulkImportJob');
}
return $builder;
}

View File

@@ -81,7 +81,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
/**
* @var string The website of the company
*/
#[Assert\Url]
#[Assert\Url(requireTld: false)]
#[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
#[ORM\Column(type: Types::STRING)]
#[Assert\Length(max: 255)]

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\InfoProviderSystem;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
enum BulkImportJobStatus: string
{
case PENDING = 'pending';
case IN_PROGRESS = 'in_progress';
case COMPLETED = 'completed';
case STOPPED = 'stopped';
case FAILED = 'failed';
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\InfoProviderSystem;
enum BulkImportPartStatus: string
{
case PENDING = 'pending';
case COMPLETED = 'completed';
case SKIPPED = 'skipped';
case FAILED = 'failed';
}

View File

@@ -0,0 +1,449 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\InfoProviderSystem;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Part;
use App\Entity\UserSystem\User;
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'bulk_info_provider_import_jobs')]
class BulkInfoProviderImportJob extends AbstractDBElement
{
#[ORM\Column(type: Types::TEXT)]
private string $name = '';
#[ORM\Column(type: Types::JSON)]
private array $fieldMappings = [];
/**
* @var BulkSearchFieldMappingDTO[] The deserialized field mappings DTOs, cached for performance
*/
private ?array $fieldMappingsDTO = null;
#[ORM\Column(type: Types::JSON)]
private array $searchResults = [];
/**
* @var BulkSearchResponseDTO|null The deserialized search results DTO, cached for performance
*/
private ?BulkSearchResponseDTO $searchResultsDTO = null;
#[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportJobStatus::class)]
private BulkImportJobStatus $status = BulkImportJobStatus::PENDING;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $completedAt = null;
#[ORM\Column(type: Types::BOOLEAN)]
private bool $prefetchDetails = false;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $createdBy = null;
/** @var Collection<int, BulkInfoProviderImportJobPart> */
#[ORM\OneToMany(targetEntity: BulkInfoProviderImportJobPart::class, mappedBy: 'job', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $jobParts;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->jobParts = new ArrayCollection();
}
public function getName(): string
{
return $this->name;
}
public function getDisplayNameKey(): string
{
return 'info_providers.bulk_import.job_name_template';
}
public function getDisplayNameParams(): array
{
return ['%count%' => $this->getPartCount()];
}
public function getFormattedTimestamp(): string
{
return $this->createdAt->format('Y-m-d H:i:s');
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getJobParts(): Collection
{
return $this->jobParts;
}
public function addJobPart(BulkInfoProviderImportJobPart $jobPart): self
{
if (!$this->jobParts->contains($jobPart)) {
$this->jobParts->add($jobPart);
$jobPart->setJob($this);
}
return $this;
}
public function removeJobPart(BulkInfoProviderImportJobPart $jobPart): self
{
if ($this->jobParts->removeElement($jobPart)) {
if ($jobPart->getJob() === $this) {
$jobPart->setJob(null);
}
}
return $this;
}
public function getPartIds(): array
{
return $this->jobParts->map(fn($jobPart) => $jobPart->getPart()->getId())->toArray();
}
public function setPartIds(array $partIds): self
{
// This method is kept for backward compatibility but should be replaced with addJobPart
// Clear existing job parts
$this->jobParts->clear();
// Add new job parts (this would need the actual Part entities, not just IDs)
// This is a simplified implementation - in practice, you'd want to pass Part entities
return $this;
}
public function addPart(Part $part): self
{
$jobPart = new BulkInfoProviderImportJobPart($this, $part);
$this->addJobPart($jobPart);
return $this;
}
/**
* @return BulkSearchFieldMappingDTO[] The deserialized field mappings
*/
public function getFieldMappings(): array
{
if ($this->fieldMappingsDTO === null) {
// Lazy load the DTOs from the raw JSON data
$this->fieldMappingsDTO = array_map(
static fn($data) => BulkSearchFieldMappingDTO::fromSerializableArray($data),
$this->fieldMappings
);
}
return $this->fieldMappingsDTO;
}
/**
* @param BulkSearchFieldMappingDTO[] $fieldMappings
* @return $this
*/
public function setFieldMappings(array $fieldMappings): self
{
//Ensure that we are dealing with the objects here
if (count($fieldMappings) > 0 && !$fieldMappings[0] instanceof BulkSearchFieldMappingDTO) {
throw new \InvalidArgumentException('Expected an array of FieldMappingDTO objects');
}
$this->fieldMappingsDTO = $fieldMappings;
$this->fieldMappings = array_map(
static fn(BulkSearchFieldMappingDTO $dto) => $dto->toSerializableArray(),
$fieldMappings
);
return $this;
}
public function getSearchResultsRaw(): array
{
return $this->searchResults;
}
public function setSearchResultsRaw(array $searchResults): self
{
$this->searchResults = $searchResults;
return $this;
}
public function setSearchResults(BulkSearchResponseDTO $searchResponse): self
{
$this->searchResultsDTO = $searchResponse;
$this->searchResults = $searchResponse->toSerializableRepresentation();
return $this;
}
public function getSearchResults(EntityManagerInterface $entityManager): BulkSearchResponseDTO
{
if ($this->searchResultsDTO === null) {
// Lazy load the DTO from the raw JSON data
$this->searchResultsDTO = BulkSearchResponseDTO::fromSerializableRepresentation($this->searchResults, $entityManager);
}
return $this->searchResultsDTO;
}
public function hasSearchResults(): bool
{
return !empty($this->searchResults);
}
public function getStatus(): BulkImportJobStatus
{
return $this->status;
}
public function setStatus(BulkImportJobStatus $status): self
{
$this->status = $status;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getCompletedAt(): ?\DateTimeImmutable
{
return $this->completedAt;
}
public function setCompletedAt(?\DateTimeImmutable $completedAt): self
{
$this->completedAt = $completedAt;
return $this;
}
public function isPrefetchDetails(): bool
{
return $this->prefetchDetails;
}
public function setPrefetchDetails(bool $prefetchDetails): self
{
$this->prefetchDetails = $prefetchDetails;
return $this;
}
public function getCreatedBy(): User
{
return $this->createdBy;
}
public function setCreatedBy(User $createdBy): self
{
$this->createdBy = $createdBy;
return $this;
}
public function getProgress(): array
{
$progress = [];
foreach ($this->jobParts as $jobPart) {
$progressData = [
'status' => $jobPart->getStatus()->value
];
// Only include completed_at if it's not null
if ($jobPart->getCompletedAt() !== null) {
$progressData['completed_at'] = $jobPart->getCompletedAt()->format('c');
}
// Only include reason if it's not null
if ($jobPart->getReason() !== null) {
$progressData['reason'] = $jobPart->getReason();
}
$progress[$jobPart->getPart()->getId()] = $progressData;
}
return $progress;
}
public function markAsCompleted(): self
{
$this->status = BulkImportJobStatus::COMPLETED;
$this->completedAt = new \DateTimeImmutable();
return $this;
}
public function markAsFailed(): self
{
$this->status = BulkImportJobStatus::FAILED;
$this->completedAt = new \DateTimeImmutable();
return $this;
}
public function markAsStopped(): self
{
$this->status = BulkImportJobStatus::STOPPED;
$this->completedAt = new \DateTimeImmutable();
return $this;
}
public function markAsInProgress(): self
{
$this->status = BulkImportJobStatus::IN_PROGRESS;
return $this;
}
public function isPending(): bool
{
return $this->status === BulkImportJobStatus::PENDING;
}
public function isInProgress(): bool
{
return $this->status === BulkImportJobStatus::IN_PROGRESS;
}
public function isCompleted(): bool
{
return $this->status === BulkImportJobStatus::COMPLETED;
}
public function isFailed(): bool
{
return $this->status === BulkImportJobStatus::FAILED;
}
public function isStopped(): bool
{
return $this->status === BulkImportJobStatus::STOPPED;
}
public function canBeStopped(): bool
{
return $this->status === BulkImportJobStatus::PENDING || $this->status === BulkImportJobStatus::IN_PROGRESS;
}
public function getPartCount(): int
{
return $this->jobParts->count();
}
public function getResultCount(): int
{
$count = 0;
foreach ($this->searchResults as $partResult) {
$count += count($partResult['search_results'] ?? []);
}
return $count;
}
public function markPartAsCompleted(int $partId): self
{
$jobPart = $this->findJobPartByPartId($partId);
if ($jobPart) {
$jobPart->markAsCompleted();
}
return $this;
}
public function markPartAsSkipped(int $partId, string $reason = ''): self
{
$jobPart = $this->findJobPartByPartId($partId);
if ($jobPart) {
$jobPart->markAsSkipped($reason);
}
return $this;
}
public function markPartAsPending(int $partId): self
{
$jobPart = $this->findJobPartByPartId($partId);
if ($jobPart) {
$jobPart->markAsPending();
}
return $this;
}
public function isPartCompleted(int $partId): bool
{
$jobPart = $this->findJobPartByPartId($partId);
return $jobPart ? $jobPart->isCompleted() : false;
}
public function isPartSkipped(int $partId): bool
{
$jobPart = $this->findJobPartByPartId($partId);
return $jobPart ? $jobPart->isSkipped() : false;
}
public function getCompletedPartsCount(): int
{
return $this->jobParts->filter(fn($jobPart) => $jobPart->isCompleted())->count();
}
public function getSkippedPartsCount(): int
{
return $this->jobParts->filter(fn($jobPart) => $jobPart->isSkipped())->count();
}
private function findJobPartByPartId(int $partId): ?BulkInfoProviderImportJobPart
{
foreach ($this->jobParts as $jobPart) {
if ($jobPart->getPart()->getId() === $partId) {
return $jobPart;
}
}
return null;
}
public function getProgressPercentage(): float
{
$total = $this->getPartCount();
if ($total === 0) {
return 100.0;
}
$completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount();
return round(($completed / $total) * 100, 1);
}
public function isAllPartsCompleted(): bool
{
$total = $this->getPartCount();
if ($total === 0) {
return true;
}
$completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount();
return $completed >= $total;
}
}

View File

@@ -0,0 +1,182 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Entity\InfoProviderSystem;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Part;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'bulk_info_provider_import_job_parts')]
#[ORM\UniqueConstraint(name: 'unique_job_part', columns: ['job_id', 'part_id'])]
class BulkInfoProviderImportJobPart extends AbstractDBElement
{
#[ORM\ManyToOne(targetEntity: BulkInfoProviderImportJob::class, inversedBy: 'jobParts')]
#[ORM\JoinColumn(nullable: false)]
private BulkInfoProviderImportJob $job;
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'bulkImportJobParts')]
#[ORM\JoinColumn(nullable: false)]
private Part $part;
#[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportPartStatus::class)]
private BulkImportPartStatus $status = BulkImportPartStatus::PENDING;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $reason = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $completedAt = null;
public function __construct(BulkInfoProviderImportJob $job, Part $part)
{
$this->job = $job;
$this->part = $part;
}
public function getJob(): BulkInfoProviderImportJob
{
return $this->job;
}
public function setJob(?BulkInfoProviderImportJob $job): self
{
$this->job = $job;
return $this;
}
public function getPart(): Part
{
return $this->part;
}
public function setPart(?Part $part): self
{
$this->part = $part;
return $this;
}
public function getStatus(): BulkImportPartStatus
{
return $this->status;
}
public function setStatus(BulkImportPartStatus $status): self
{
$this->status = $status;
return $this;
}
public function getReason(): ?string
{
return $this->reason;
}
public function setReason(?string $reason): self
{
$this->reason = $reason;
return $this;
}
public function getCompletedAt(): ?\DateTimeImmutable
{
return $this->completedAt;
}
public function setCompletedAt(?\DateTimeImmutable $completedAt): self
{
$this->completedAt = $completedAt;
return $this;
}
public function markAsCompleted(): self
{
$this->status = BulkImportPartStatus::COMPLETED;
$this->completedAt = new \DateTimeImmutable();
return $this;
}
public function markAsSkipped(string $reason = ''): self
{
$this->status = BulkImportPartStatus::SKIPPED;
$this->reason = $reason;
$this->completedAt = new \DateTimeImmutable();
return $this;
}
public function markAsFailed(string $reason = ''): self
{
$this->status = BulkImportPartStatus::FAILED;
$this->reason = $reason;
$this->completedAt = new \DateTimeImmutable();
return $this;
}
public function markAsPending(): self
{
$this->status = BulkImportPartStatus::PENDING;
$this->reason = null;
$this->completedAt = null;
return $this;
}
public function isPending(): bool
{
return $this->status === BulkImportPartStatus::PENDING;
}
public function isCompleted(): bool
{
return $this->status === BulkImportPartStatus::COMPLETED;
}
public function isSkipped(): bool
{
return $this->status === BulkImportPartStatus::SKIPPED;
}
public function isFailed(): bool
{
return $this->status === BulkImportPartStatus::FAILED;
}
}

View File

@@ -24,6 +24,8 @@ namespace App\Entity\LogSystem;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Category;
@@ -67,6 +69,8 @@ enum LogTargetType: int
case LABEL_PROFILE = 19;
case PART_ASSOCIATION = 20;
case BULK_INFO_PROVIDER_IMPORT_JOB = 21;
case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22;
/**
* Returns the class name of the target type or null if the target type is NONE.
@@ -96,6 +100,8 @@ enum LogTargetType: int
self::PARAMETER => AbstractParameter::class,
self::LABEL_PROFILE => LabelProfile::class,
self::PART_ASSOCIATION => PartAssociation::class,
self::BULK_INFO_PROVIDER_IMPORT_JOB => BulkInfoProviderImportJob::class,
self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => BulkInfoProviderImportJobPart::class,
};
}

View File

@@ -22,8 +22,6 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use App\ApiPlatform\Filter\TagFilter;
use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
@@ -40,10 +38,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\EntityFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\ApiPlatform\Filter\PartStoragelocationFilter;
use App\ApiPlatform\Filter\TagFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\PartAttachment;
use App\Entity\EDA\EDAPartInfo;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use App\Entity\Parameters\ParametersTrait;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parts\PartTraits\AdvancedPropertyTrait;
@@ -59,6 +59,7 @@ use App\Repository\PartRepository;
use App\Validator\Constraints\UniqueObjectCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
@@ -83,8 +84,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
#[ApiResource(
operations: [
new Get(normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read',
'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'],
new Get(normalizationContext: [
'groups' => [
'part:read',
'provider_reference:read',
'api:basic:read',
'part_lot:read',
'orderdetail:read',
'pricedetail:read',
'parameter:read',
'attachment:read',
'eda_info:read'
],
'openapi_definition_name' => 'Read',
], security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@parts.read")'),
@@ -92,7 +103,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiFilter(PropertyFilter::class)]
@@ -100,7 +111,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])]
#[ApiFilter(TagFilter::class, properties: ["tags"])]
#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])]
#[ApiFilter(BooleanFilter::class, properties: ["favorite", "needs_review"])]
#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
@@ -160,6 +171,12 @@ class Part extends AttachmentContainingDBElement
#[Groups(['part:read'])]
protected ?\DateTimeImmutable $lastModified = null;
/**
* @var Collection<int, BulkInfoProviderImportJobPart>
*/
#[ORM\OneToMany(mappedBy: 'part', targetEntity: BulkInfoProviderImportJobPart::class, cascade: ['remove'], orphanRemoval: true)]
protected Collection $bulkImportJobParts;
public function __construct()
{
@@ -172,6 +189,7 @@ class Part extends AttachmentContainingDBElement
$this->associated_parts_as_owner = new ArrayCollection();
$this->associated_parts_as_other = new ArrayCollection();
$this->bulkImportJobParts = new ArrayCollection();
//By default, the part has no provider
$this->providerReference = InfoProviderReference::noProvider();
@@ -230,4 +248,38 @@ class Part extends AttachmentContainingDBElement
}
}
}
/**
* Get all bulk import job parts for this part
* @return Collection<int, BulkInfoProviderImportJobPart>
*/
public function getBulkImportJobParts(): Collection
{
return $this->bulkImportJobParts;
}
/**
* Add a bulk import job part to this part
*/
public function addBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
{
if (!$this->bulkImportJobParts->contains($jobPart)) {
$this->bulkImportJobParts->add($jobPart);
$jobPart->setPart($this);
}
return $this;
}
/**
* Remove a bulk import job part from this part
*/
public function removeBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
{
if ($this->bulkImportJobParts->removeElement($jobPart)) {
if ($jobPart->getPart() === $this) {
$jobPart->setPart(null);
}
}
return $this;
}
}

View File

@@ -49,7 +49,7 @@ trait ManufacturerTrait
/**
* @var string The url to the part on the manufacturer's homepage
*/
#[Assert\Url]
#[Assert\Url(requireTld: false)]
#[Groups(['full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $manufacturer_product_url = '';

View File

@@ -124,7 +124,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
/**
* @var string The URL to the product on the supplier's website
*/
#[Assert\Url]
#[Assert\Url(requireTld: false)]
#[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $supplier_product_url = '';

View File

@@ -170,6 +170,7 @@ class EventLoggerListener
public function hasFieldRestrictions(AbstractDBElement $element): bool
{
foreach (array_keys(static::FIELD_BLACKLIST) as $class) {
/** @var string $class */
if ($element instanceof $class) {
return true;
}
@@ -184,6 +185,7 @@ class EventLoggerListener
public function shouldFieldBeSaved(AbstractDBElement $element, string $field_name): bool
{
foreach (static::FIELD_BLACKLIST as $class => $blacklist) {
/** @var string $class */
if ($element instanceof $class && in_array($field_name, $blacklist, true)) {
return false;
}
@@ -215,6 +217,7 @@ class EventLoggerListener
$mappings = $metadata->getAssociationMappings();
//Check if class is whitelisted for CollectionElementDeleted entry
foreach (static::TRIGGER_ASSOCIATION_LOG_WHITELIST as $class => $whitelist) {
/** @var string $class */
if ($entity instanceof $class) {
//Check names
foreach ($mappings as $field => $mapping) {

View File

@@ -59,6 +59,8 @@ class ImportType extends AbstractType
'XML' => 'xml',
'CSV' => 'csv',
'YAML' => 'yaml',
'XLSX' => 'xlsx',
'XLS' => 'xls',
],
'label' => 'export.format',
'disabled' => $disabled,

View File

@@ -100,7 +100,7 @@ class LogFilterType extends AbstractType
]);
$builder->add('user', UserEntityConstraintType::class, [
'label' => 'log.user',
'label' => 'log.user',
]);
$builder->add('targetType', EnumConstraintType::class, [
@@ -128,11 +128,13 @@ class LogFilterType extends AbstractType
LogTargetType::PARAMETER => 'parameter.label',
LogTargetType::LABEL_PROFILE => 'label_profile.label',
LogTargetType::PART_ASSOCIATION => 'part_association.label',
LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label',
LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label',
},
]);
$builder->add('targetId', NumberConstraintType::class, [
'label' => 'log.target_id',
'label' => 'log.target_id',
'min' => 1,
'step' => 1,
]);

View File

@@ -22,9 +22,12 @@ declare(strict_types=1);
*/
namespace App\Form\Filters;
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
use App\DataTables\Filters\PartFilter;
use App\Entity\Attachments\AttachmentType;
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
use App\Entity\InfoProviderSystem\BulkImportPartStatus;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
@@ -33,8 +36,12 @@ use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\ProjectSystem\Project;
use App\Form\Filters\Constraints\BooleanConstraintType;
use App\Form\Filters\Constraints\BulkImportJobExistsConstraintType;
use App\Form\Filters\Constraints\BulkImportJobStatusConstraintType;
use App\Form\Filters\Constraints\BulkImportPartStatusConstraintType;
use App\Form\Filters\Constraints\ChoiceConstraintType;
use App\Form\Filters\Constraints\DateTimeConstraintType;
use App\Form\Filters\Constraints\EnumConstraintType;
use App\Form\Filters\Constraints\NumberConstraintType;
use App\Form\Filters\Constraints\ParameterConstraintType;
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
@@ -50,6 +57,8 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use function Symfony\Component\Translation\t;
class PartFilterType extends AbstractType
{
public function __construct(private readonly Security $security)
@@ -298,6 +307,31 @@ class PartFilterType extends AbstractType
}
/**************************************************************************
* Bulk Import Job tab
**************************************************************************/
if ($this->security->isGranted('@info_providers.create_parts')) {
$builder
->add('inBulkImportJob', BooleanConstraintType::class, [
'label' => 'part.filter.in_bulk_import_job',
])
->add('bulkImportJobStatus', EnumConstraintType::class, [
'enum_class' => BulkImportJobStatus::class,
'label' => 'part.filter.bulk_import_job_status',
'choice_label' => function (BulkImportJobStatus $value) {
return t('bulk_import.status.' . $value->value);
},
])
->add('bulkImportPartStatus', EnumConstraintType::class, [
'enum_class' => BulkImportPartStatus::class,
'label' => 'part.filter.bulk_import_part_status',
'choice_label' => function (BulkImportPartStatus $value) {
return t('bulk_import.part_status.' . $value->value);
},
])
;
}
$builder->add('submit', SubmitType::class, [
'label' => 'filter.submit',

View File

@@ -38,7 +38,7 @@ class EnforceEventCommentTypesType extends AbstractType
return EnumType::class;
}
public function configureOptions(OptionsResolver $resolver)
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'multiple' => true,
@@ -46,4 +46,4 @@ class EnforceEventCommentTypesType extends AbstractType
'empty_data' => [],
]);
}
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\InfoProviderSystem;
use App\Entity\Parts\Part;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BulkProviderSearchType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$parts = $options['parts'];
$builder->add('part_configurations', CollectionType::class, [
'entry_type' => PartProviderConfigurationType::class,
'entry_options' => [
'label' => false,
],
'allow_add' => false,
'allow_delete' => false,
'label' => false,
]);
$builder->add('submit', SubmitType::class, [
'label' => 'info_providers.bulk_search.submit'
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'parts' => [],
]);
$resolver->setRequired('parts');
}
}

View File

@@ -0,0 +1,75 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\InfoProviderSystem;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class FieldToProviderMappingType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$fieldChoices = $options['field_choices'] ?? [];
$builder->add('field', ChoiceType::class, [
'label' => 'info_providers.bulk_search.search_field',
'choices' => $fieldChoices,
'expanded' => false,
'multiple' => false,
'required' => false,
'placeholder' => 'info_providers.bulk_search.field.select',
]);
$builder->add('providers', ProviderSelectType::class, [
'label' => 'info_providers.bulk_search.providers',
'help' => 'info_providers.bulk_search.providers.help',
'required' => false,
]);
$builder->add('priority', IntegerType::class, [
'label' => 'info_providers.bulk_search.priority',
'help' => 'info_providers.bulk_search.priority.help',
'required' => false,
'data' => 1, // Default priority
'attr' => [
'min' => 1,
'max' => 10,
'class' => 'form-control-sm',
'style' => 'width: 80px;'
],
'constraints' => [
new \Symfony\Component\Validator\Constraints\Range(['min' => 1, 'max' => 10]),
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'field_choices' => [],
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\InfoProviderSystem;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class GlobalFieldMappingType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$fieldChoices = $options['field_choices'] ?? [];
$builder->add('field_mappings', CollectionType::class, [
'entry_type' => FieldToProviderMappingType::class,
'entry_options' => [
'label' => false,
'field_choices' => $fieldChoices,
],
'allow_add' => true,
'allow_delete' => true,
'prototype' => true,
'label' => false,
]);
$builder->add('prefetch_details', CheckboxType::class, [
'label' => 'info_providers.bulk_import.prefetch_details',
'required' => false,
'help' => 'info_providers.bulk_import.prefetch_details_help',
]);
$builder->add('submit', SubmitType::class, [
'label' => 'info_providers.bulk_import.search.submit'
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'field_choices' => [],
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\InfoProviderSystem;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
class PartProviderConfigurationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('part_id', HiddenType::class);
$builder->add('search_field', ChoiceType::class, [
'label' => 'info_providers.bulk_search.search_field',
'choices' => [
'info_providers.bulk_search.field.mpn' => 'mpn',
'info_providers.bulk_search.field.name' => 'name',
'info_providers.bulk_search.field.digikey_spn' => 'digikey_spn',
'info_providers.bulk_search.field.mouser_spn' => 'mouser_spn',
'info_providers.bulk_search.field.lcsc_spn' => 'lcsc_spn',
'info_providers.bulk_search.field.farnell_spn' => 'farnell_spn',
],
'expanded' => false,
'multiple' => false,
]);
$builder->add('providers', ProviderSelectType::class, [
'label' => 'info_providers.bulk_search.providers',
'help' => 'info_providers.bulk_search.providers.help',
]);
}
}

View File

@@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\ChoiceList\ChoiceList;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProviderSelectType extends AbstractType
@@ -44,13 +45,43 @@ class ProviderSelectType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'choices' => $this->providerRegistry->getActiveProviders(),
'choice_label' => ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']),
'choice_value' => ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey()),
$providers = $this->providerRegistry->getActiveProviders();
'multiple' => true,
]);
$resolver->setDefault('input', 'object');
$resolver->setAllowedTypes('input', 'string');
//Either the form returns the provider objects or their keys
$resolver->setAllowedValues('input', ['object', 'string']);
$resolver->setDefault('multiple', true);
$resolver->setDefault('choices', function (Options $options) use ($providers) {
if ('object' === $options['input']) {
return $this->providerRegistry->getActiveProviders();
}
$tmp = [];
foreach ($providers as $provider) {
$name = $provider->getProviderInfo()['name'];
$tmp[$name] = $provider->getProviderKey();
}
return $tmp;
});
//The choice_label and choice_value only needs to be set if we want the objects
$resolver->setDefault('choice_label', function (Options $options){
if ('object' === $options['input']) {
return ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']);
}
return null;
});
$resolver->setDefault('choice_value', function (Options $options) {
if ('object' === $options['input']) {
return ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey());
}
return null;
});
}
}
}

View File

@@ -87,6 +87,16 @@ class LabelDialogType extends AbstractType
]
]);
if ($options['profile'] !== null) {
$builder->add('update_profile', SubmitType::class, [
'label' => 'label_generator.update_profile',
'disabled' => !$this->security->isGranted('edit', $options['profile']),
'attr' => [
'class' => 'btn btn-outline-success'
]
]);
}
$builder->add('update', SubmitType::class, [
'label' => 'label_generator.update',
]);
@@ -97,5 +107,6 @@ class LabelDialogType extends AbstractType
parent::configureOptions($resolver);
$resolver->setDefault('mapped', false);
$resolver->setDefault('disable_options', false);
$resolver->setDefault('profile', null);
}
}

View File

@@ -44,10 +44,10 @@ class LocaleSelectType extends AbstractType
return LocaleType::class;
}
public function configureOptions(OptionsResolver $resolver)
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'preferred_choices' => $this->preferred_languages,
]);
}
}
}

View File

@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Security;
use App\Entity\UserSystem\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
@@ -51,7 +52,7 @@ final class UserChecker implements UserCheckerInterface
*
* @throws AccountStatusException
*/
public function checkPostAuth(UserInterface $user): void
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void
{
if (!$user instanceof User) {
return;

View File

@@ -41,6 +41,7 @@ use App\Entity\Attachments\UserAttachment;
use RuntimeException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function in_array;
@@ -56,7 +57,7 @@ final class AttachmentVoter extends Voter
{
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
//This voter only works for attachments
@@ -65,7 +66,8 @@ final class AttachmentVoter extends Voter
}
if ($attribute === 'show_private') {
return $this->helper->isGranted($token, 'attachments', 'show_private');
$vote?->addReason('User is not allowed to view private attachments.');
return $this->helper->isGranted($token, 'attachments', 'show_private', $vote);
}
@@ -111,7 +113,8 @@ final class AttachmentVoter extends Voter
throw new RuntimeException('Encountered unknown Parameter type: ' . $subject);
}
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute));
$vote?->addReason('User is not allowed to '.$this->mapOperation($attribute).' attachments of type '.$param.'.');
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute), $vote);
}
return false;

View File

@@ -27,6 +27,7 @@ use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -46,7 +47,7 @@ class BOMEntryVoter extends Voter
return $this->supportsAttribute($attribute) && is_a($subject, ProjectBOMEntry::class, true);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if (!is_a($subject, ProjectBOMEntry::class, true)) {
return false;
@@ -87,4 +88,4 @@ class BOMEntryVoter extends Voter
{
return $subjectType === 'string' || is_a($subjectType, ProjectBOMEntry::class, true);
}
}
}

View File

@@ -25,6 +25,7 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\Group;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -43,9 +44,9 @@ final class GroupVoter extends Voter
*
* @param string $attribute
*/
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
return $this->helper->isGranted($token, 'groups', $attribute);
return $this->helper->isGranted($token, 'groups', $attribute, $vote);
}
/**

View File

@@ -26,6 +26,7 @@ namespace App\Security\Voter;
use App\Services\UserSystem\PermissionManager;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -41,7 +42,7 @@ final class HasAccessPermissionsVoter extends Voter
{
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
return $this->permissionManager->hasAnyPermissionSetToAllowInherited($user);
@@ -56,4 +57,4 @@ final class HasAccessPermissionsVoter extends Voter
{
return $attribute === self::ROLE;
}
}
}

View File

@@ -26,6 +26,7 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\User;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -47,9 +48,16 @@ final class ImpersonateUserVoter extends Voter
&& $subject instanceof UserInterface;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
return $this->helper->isGranted($token, 'users', 'impersonate');
$result = $this->helper->isGranted($token, 'users', 'impersonate');
if ($result === false) {
$vote?->addReason('User is not allowed to impersonate other users.');
$this->helper->addReason($vote, 'users', 'impersonate');
}
return $result;
}
public function supportsAttribute(string $attribute): bool
@@ -61,4 +69,4 @@ final class ImpersonateUserVoter extends Voter
{
return is_a($subjectType, User::class, true);
}
}
}

View File

@@ -44,6 +44,7 @@ namespace App\Security\Voter;
use App\Entity\LabelSystem\LabelProfile;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -58,14 +59,15 @@ final class LabelProfileVoter extends Voter
'delete' => 'delete_profiles',
'show_history' => 'show_history',
'revert_element' => 'revert_element',
'import' => 'import',
];
public function __construct(private readonly VoterHelper $helper)
{}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute]);
return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute], $vote);
}
protected function supports($attribute, $subject): bool

View File

@@ -26,6 +26,7 @@ use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\LogSystem\AbstractLogEntry;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -39,7 +40,7 @@ final class LogEntryVoter extends Voter
{
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
@@ -48,19 +49,19 @@ final class LogEntryVoter extends Voter
}
if ('delete' === $attribute) {
return $this->helper->isGranted($token, 'system', 'delete_logs');
return $this->helper->isGranted($token, 'system', 'delete_logs', $vote);
}
if ('read' === $attribute) {
//Allow read of the users own log entries
if (
$subject->getUser() === $user
&& $this->helper->isGranted($token, 'self', 'show_logs')
&& $this->helper->isGranted($token, 'self', 'show_logs', $vote)
) {
return true;
}
return $this->helper->isGranted($token, 'system', 'show_logs');
return $this->helper->isGranted($token, 'system', 'show_logs', $vote);
}
if ('show_details' === $attribute) {

View File

@@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Orderdetail;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -59,7 +60,7 @@ final class OrderdetailVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if (! is_a($subject, Orderdetail::class, true)) {
throw new \RuntimeException('This voter can only handle Orderdetail objects!');
@@ -75,7 +76,7 @@ final class OrderdetailVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View File

@@ -39,6 +39,7 @@ use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter;
use RuntimeException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -53,7 +54,7 @@ final class ParameterVoter extends Voter
{
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
//return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
@@ -108,7 +109,7 @@ final class ParameterVoter extends Voter
throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? $subject::class : $subject));
}
return $this->helper->isGranted($token, $param, $attribute);
return $this->helper->isGranted($token, $param, $attribute, $vote);
}
protected function supports(string $attribute, $subject): bool

View File

@@ -46,6 +46,7 @@ use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -61,7 +62,7 @@ final class PartAssociationVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if (!is_string($subject) && !$subject instanceof PartAssociation) {
throw new \RuntimeException('Invalid subject type!');
@@ -77,7 +78,7 @@ final class PartAssociationVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getOwner() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View File

@@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -59,13 +60,13 @@ final class PartLotVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
if (in_array($attribute, ['withdraw', 'add', 'move'], true))
{
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute);
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote);
$lot_permission = true;
//If the lot has an owner, we need to check if the user is the owner of the lot to be allowed to withdraw it.
@@ -73,6 +74,10 @@ final class PartLotVoter extends Voter
$lot_permission = $subject->getOwner() === $user || $subject->getOwner()->getID() === $user->getID();
}
if (!$lot_permission) {
$vote->addReason('User is not the owner of the lot.');
}
return $base_permission && $lot_permission;
}
@@ -86,7 +91,7 @@ final class PartLotVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View File

@@ -25,6 +25,7 @@ namespace App\Security\Voter;
use App\Entity\Parts\Part;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -52,10 +53,9 @@ final class PartVoter extends Voter
return false;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
//Null concealing operator means, that no
return $this->helper->isGranted($token, 'parts', $attribute);
return $this->helper->isGranted($token, 'parts', $attribute, $vote);
}
public function supportsAttribute(string $attribute): bool

View File

@@ -24,6 +24,7 @@ namespace App\Security\Voter;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -39,12 +40,17 @@ final class PermissionVoter extends Voter
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$attribute = ltrim($attribute, '@');
[$perm, $op] = explode('.', $attribute);
return $this->helper->isGranted($token, $perm, $op);
$result = $this->helper->isGranted($token, $perm, $op);
if ($result === false) {
$this->helper->addReason($vote, $perm, $op);
}
return $result;
}
public function supportsAttribute(string $attribute): bool

View File

@@ -47,6 +47,7 @@ use App\Entity\PriceInformations\Orderdetail;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Pricedetail;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -60,7 +61,7 @@ final class PricedetailVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$operation = match ($attribute) {
'read' => 'read',
@@ -72,7 +73,7 @@ final class PricedetailVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getOrderdetail() instanceof Orderdetail || !$subject->getOrderdetail()->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View File

@@ -33,6 +33,7 @@ use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function is_object;
@@ -113,10 +114,10 @@ final class StructureVoter extends Voter
*
* @param string $attribute
*/
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$permission_name = $this->instanceToPermissionName($subject);
//Just resolve the permission
return $this->helper->isGranted($token, $permission_name, $attribute);
return $this->helper->isGranted($token, $permission_name, $attribute, $vote);
}
}

View File

@@ -26,6 +26,7 @@ use App\Entity\UserSystem\User;
use App\Services\UserSystem\PermissionManager;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function in_array;
@@ -79,7 +80,7 @@ final class UserVoter extends Voter
*
* @param string $attribute
*/
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
@@ -97,7 +98,7 @@ final class UserVoter extends Voter
if (($subject instanceof User) && $subject->getID() === $user->getID() &&
$this->helper->isValidOperation('self', $attribute)) {
//Then we also need to check the self permission
$tmp = $this->helper->isGranted($token, 'self', $attribute);
$tmp = $this->helper->isGranted($token, 'self', $attribute, $vote);
//But if the self value is not allowed then use just the user value:
if ($tmp) {
return $tmp;
@@ -106,7 +107,7 @@ final class UserVoter extends Voter
//Else just check user permission:
if ($this->helper->isValidOperation('users', $attribute)) {
return $this->helper->isGranted($token, 'users', $attribute);
return $this->helper->isGranted($token, 'users', $attribute, $vote);
}
return false;

View File

@@ -57,6 +57,9 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/
class AttachmentSubmitHandler
{
/**
* @var array<string, string> The mapping used to determine which folder will be used for an attachment type
*/
protected array $folder_mapping;
private ?int $max_upload_size_bytes = null;
@@ -160,6 +163,7 @@ class AttachmentSubmitHandler
} else {
//If not, check for instance of:
foreach ($this->folder_mapping as $class => $folder) {
/** @var string $class */
if ($attachment instanceof $class) {
$prefix = $folder;
break;

View File

@@ -22,13 +22,13 @@ declare(strict_types=1);
namespace App\Services;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Parts\PartAssociation;
use App\Entity\ProjectSystem\Project;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Category;
@@ -36,12 +36,14 @@ use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartAssociation;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
@@ -79,6 +81,8 @@ class ElementTypeNameGenerator
AbstractParameter::class => $this->translator->trans('parameter.label'),
LabelProfile::class => $this->translator->trans('label_profile.label'),
PartAssociation::class => $this->translator->trans('part_association.label'),
BulkInfoProviderImportJob::class => $this->translator->trans('bulk_info_provider_import_job.label'),
BulkInfoProviderImportJobPart::class => $this->translator->trans('bulk_info_provider_import_job_part.label'),
];
}
@@ -130,10 +134,10 @@ class ElementTypeNameGenerator
{
$type = $this->getLocalizedTypeLabel($entity);
if ($use_html) {
return '<i>'.$type.':</i> '.htmlspecialchars($entity->getName());
return '<i>' . $type . ':</i> ' . htmlspecialchars($entity->getName());
}
return $type.': '.$entity->getName();
return $type . ': ' . $entity->getName();
}

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