Compare commits

...

193 Commits

Author SHA1 Message Date
Jan Böhmer
70cde4c3a8 Bumped version to 2.7.1 2026-02-16 18:34:20 +01:00
Jan Böhmer
28e6ca52fe New translations messages.en.xlf (German) (#1249) 2026-02-16 18:30:41 +01:00
Jan Böhmer
5b4c1505b7 Fixed visual bug of tags column in parts lot 2026-02-16 18:29:34 +01:00
Jan Böhmer
8ad3c2e612 Allow stocktake date to be empty on part lot form
Fixes issue #1250
2026-02-16 18:25:41 +01:00
Jan Böhmer
d7ed2225b4 Ensure that part tables are correctly sorted on initial load 2026-02-16 15:09:55 +01:00
Jan Böhmer
7d6b84af3d Bumped version to 2.7.0 2026-02-16 13:32:13 +01:00
Copilot
80492a7b68 Use native ARM runners for ARM Docker image builds (#1248)
* Initial plan

* Use ARM runners for ARM Docker image builds

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Fix artifact naming and add comments for latest=false flavor

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Remove trailing commas from tag configuration in merge job

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Remove duplicate tag entries and clean up configuration

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
2026-02-16 13:15:52 +01:00
Copilot
7069af4054 Updated dockerfiles to not rely on node deb packages, that are not supported for armhf anymore
* Initial plan

* Refactor Dockerfiles to use Node.js multistage builds

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Fix node-builder stage with stub translations file

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Improve stub translations file creation using heredoc

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Build real translations in node-builder stage via cache warmup

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Improve error handling for cache warmup fallback

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Use native build platform for node-builder stage

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Do not include fallback for case that translations not exist

* Use newer format for dockerfile-frankenphp

* Dockfile added caching to package managers

* Fixed frankenphp build

* Fixed complain about missing symfony runtime

* Use caching for frankenphp dockerfile

* add target arch to dockerfile caches, to avoid problems

* add targetarch arg

* moved targetarch argument to correct position

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-02-16 12:50:52 +01:00
Jan Böhmer
05a9e4d035 Merge remote-tracking branch 'origin/master' 2026-02-15 22:33:23 +01:00
Jan Böhmer
be808e28bc Updated dependencies 2026-02-15 22:29:16 +01:00
Jan Böhmer
7354b37ef6 New Crowdin updates (#1228)
* New translations messages.en.xlf (German)

* New translations messages.en.xlf (German)

* New translations validators.en.xlf (Polish)

* New translations security.en.xlf (Danish)

* New translations security.en.xlf (Ukrainian)

* New translations security.en.xlf (German)

* New translations security.en.xlf (Hungarian)

* New translations security.en.xlf (Dutch)

* New translations security.en.xlf (Chinese Simplified)

* New translations messages.en.xlf (English)

* New translations validators.en.xlf (English)

* New translations security.en.xlf (English)

* New translations frontend.en.xlf (Danish)

* New translations frontend.en.xlf (German)

* New translations frontend.en.xlf (Hungarian)

* New translations frontend.en.xlf (Ukrainian)

* New translations frontend.en.xlf (Chinese Simplified)

* New translations frontend.en.xlf (English)

* New translations messages.en.xlf (German)
2026-02-15 22:24:00 +01:00
Jan Böhmer
6afca44897 Use xxh3 hashes instead of encoding for info provider cache keys 2026-02-15 22:19:44 +01:00
Jan Böhmer
c17cf2baa1 Fixed rendering of tristate checkboxes 2026-02-15 21:49:18 +01:00
Jan Böhmer
c00556829a Focus the first newly created number input for collection_types
Improves PR #1240
2026-02-15 21:43:47 +01:00
Jan Böhmer
f024c4b09e Merge branch 'autofocus-fields' 2026-02-15 21:37:12 +01:00
Jan Böhmer
8e0fcdb73b Added some part datatables optimization 2026-02-15 20:07:38 +01:00
Jan Böhmer
e19929249f Mark parts datatables query as read only for some memory optimizations 2026-02-15 19:30:53 +01:00
Jan Böhmer
f6764170e1 Fixed phpstan issues 2026-02-15 16:16:15 +01:00
Niklas
1641708508 Added API endpoint for generating labels (#1234)
* init API endpoint for generating labels

* Improved API docs for label endpoint

* Improved LabelGenerationProcessor

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-02-15 16:03:07 +01:00
d-buchmann
97a74815d3 Fix fallback filename (#1238)
Fixes #1231.
Modify tests to account for this case.
2026-02-15 14:41:25 +01:00
Jan Böhmer
7998cdcd71 Added hint about HTML block to twig label documentation 2026-02-15 14:24:31 +01:00
Jan Böhmer
5e9f7a11a3 Catch more errors of twig labels 2026-02-15 14:11:31 +01:00
Jan Böhmer
1c6bf3f472 Allow more useful functions in twig labels 2026-02-15 14:07:50 +01:00
Jan Böhmer
aed2652f1d Added functions to retrieve associated parts of an element within twig labels
This fixes #1239
2026-02-15 13:52:56 +01:00
Jan Böhmer
233c5e8550 Fixed phpunit and phpstan issues 2026-02-15 00:49:12 +01:00
Jan Böhmer
6b83c772cc Moved user twig functions requiring repo access to its own extension service 2026-02-15 00:28:40 +01:00
Jan Böhmer
1996db6a53 Moved remaining twig extensions to new attributes system 2026-02-15 00:23:30 +01:00
Jan Böhmer
f69b0889eb Ran rector to convert some our twig extensions to use #[AsTwigXX] attributes 2026-02-14 23:53:31 +01:00
Jan Böhmer
c8b1320bb9 Updated rector config 2026-02-14 23:50:42 +01:00
Jan Böhmer
e11cb7d5cb Fixed phpunit tests 2026-02-14 23:46:39 +01:00
Jan Böhmer
097041a43a Ran rector 2026-02-14 23:33:40 +01:00
Jan Böhmer
b21d294cf8 Ran rector and made tests final 2026-02-14 23:32:43 +01:00
Jan Böhmer
43d72faf48 Updated label fonts 2026-02-14 22:46:46 +01:00
Jan Böhmer
bc9a93d71f Removed sodium compat, as all supported PHP versions support it natively nowadays 2026-02-14 22:31:53 +01:00
Jan Böhmer
df0ac76394 Updated composer dependencies that required major version changes 2026-02-14 22:24:36 +01:00
Jan Böhmer
66040b687f Updated dependencies 2026-02-14 22:17:05 +01:00
Jan Böhmer
7a83581597 Merge branch 'gtin' 2026-02-14 22:12:39 +01:00
buchmann
47c0d78985 only autofocus if new 2026-02-11 14:26:36 +01:00
buchmann
76f0b05a09 Autofocus for frequently used input fields
Fixes #1157.
- Focus `name` field on new part
- Focus `amount` on add/withdraw modal
- Focus first "number type" input on any newly added collectionType table row... (debatable)

It would be even more favorable if the user could configure if they want to use autofocus and/or for which fields/dialogs it should be enabled.
2026-02-11 14:10:05 +01:00
Jan Böhmer
35598df354 Automatically set the stocktake permission if a user can already add and withdraw from a lot 2026-02-10 23:24:40 +01:00
Jan Böhmer
3c87fe0932 Added test for stocktake method on PartLotWithdrawAddHelper 2026-02-10 23:19:57 +01:00
Jan Böhmer
d8fdaa9529 Added a modal to stocktake / set part lots amount from info page 2026-02-10 23:17:10 +01:00
Jan Böhmer
2f9601364e Allow to set stocktake date for part lots 2026-02-10 22:23:54 +01:00
Jan Böhmer
e5231e29f2 Allow to set a global default if new orderdetails should contain VAT or not 2026-02-10 17:13:54 +01:00
Jan Böhmer
8ac8743792 Fixed phpunit tests 2026-02-10 16:54:13 +01:00
Jan Böhmer
586375d921 Moved VAT include info from pricedetail to orderdetail level
That makes implementing the form easier
2026-02-10 16:53:41 +01:00
Marc
41252d8bb9 Implement URLHandlerInfoProviderInterface in BuerklinProvider (#1235)
* Implement URLHandlerInfoProviderInterface in BuerklinProvider

Added URL handling capabilities to BuerklinProvider.

* Refactor ID extraction logic in BuerklinProvider

* Add tests for BuerklinProvider URLHandlerInfoProviderInterface

* Revert "Refactor ID extraction logic in BuerklinProvider"

This reverts commit 5f65176636.

* Exclude 'p' from valid ID return in BuerklinProvider
2026-02-10 15:26:26 +01:00
Jan Böhmer
4740b6d19e Show in part info page whether price is inclusive VAT or not 2026-02-08 22:09:36 +01:00
Jan Böhmer
5a47b15c97 Use the information from info provider whether prices includes VAT or not 2026-02-08 21:58:14 +01:00
Jan Böhmer
3bff5fa8bd Allow to set if prices contain VAT or not in orderdetail 2026-02-08 21:54:34 +01:00
Jan Böhmer
f95e39748e Fixed PHPstan issue 2026-02-08 19:37:44 +01:00
Jan Böhmer
90c82aab2e Only show the created avatar attachment type for user attachments 2026-02-08 19:31:45 +01:00
Jan Böhmer
a4c2b8f885 Added the option to only show attachment types for certain element classes 2026-02-08 19:30:06 +01:00
Jan Böhmer
2c56ec746c Improved translation 2026-02-08 16:07:11 +01:00
Jan Böhmer
35e844dd7b Allow to scan gtin barcodes and find parts via it 2026-02-08 16:06:01 +01:00
Jan Böhmer
4de6dbba27 Show GTIN in part extended info tab 2026-02-08 15:53:45 +01:00
Jan Böhmer
a962e5e019 Allow to order and filter by GTIN in part tables 2026-02-08 15:51:39 +01:00
Jan Böhmer
1130f71075 Added ability to get GTINs for reichelt and Generic WebURL 2026-02-08 15:43:50 +01:00
Jan Böhmer
fd76ca12fc Allow to import GTIN from info providers 2026-02-08 15:32:35 +01:00
Jan Böhmer
57c8368b5e Allow to edit the GTIN property of a part and validate the GTIN 2026-02-08 14:44:56 +01:00
Jan Böhmer
7fd7697c02 Added GTIN fields and others to DB 2026-02-08 14:17:58 +01:00
Jan Böhmer
c2a51e57b7 New Crowdin updates (#1227)
* New translations security.en.xlf (French)

* New translations security.en.xlf (Spanish)

* New translations security.en.xlf (Czech)

* New translations security.en.xlf (Italian)

* New translations security.en.xlf (Polish)

* New translations security.en.xlf (Russian)

* New translations frontend.en.xlf (French)

* New translations frontend.en.xlf (Spanish)

* New translations frontend.en.xlf (Czech)

* New translations frontend.en.xlf (Italian)

* New translations frontend.en.xlf (Polish)

* New translations frontend.en.xlf (Russian)
2026-02-07 19:14:35 +01:00
Jan Böhmer
cae0cd8ac1 Bumped version to 2.6.0 2026-02-07 19:07:37 +01:00
Copilot
f5841cc697 Remove outdated file source and path notes from translation files (#1225)
* Initial plan

* Remove outdated file source and path notes from all translation files

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Preserve XML declaration format with double quotes

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
2026-02-07 18:33:31 +01:00
Jan Böhmer
8104c474b7 New translations messages.en.xlf (English) (#1226) 2026-02-07 18:13:01 +01:00
Jan Böhmer
dcdc990af1 Fixed unnecessary colon in english translation 2026-02-07 17:33:44 +01:00
Jan Böhmer
aec53bd1dd Do not output HTML chars in translations escaped in CDATA to ensure consistentcy with crowdin XMLs
This should avoid some unnecessary diffs in the future
2026-02-07 17:33:32 +01:00
Jan Böhmer
81dde6fa68 Only allow to set the DELETE method via HTTP method overriding
This hardens security
2026-02-07 17:18:31 +01:00
Jan Böhmer
b144f5e383 Updated dependencies 2026-02-07 17:13:49 +01:00
Jan Böhmer
fd4eb72eb2 Merge remote-tracking branch 'origin/master' 2026-02-07 17:11:36 +01:00
Jan Böhmer
44204b9dbb New Crowdin updates (#1212)
* New translations messages.en.xlf (Danish)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (Danish)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (Danish)

* New translations messages.en.xlf (English)

* New translations validators.en.xlf (Chinese Simplified)

* New translations frontend.en.xlf (Chinese Simplified)

* New translations frontend.en.xlf (Chinese Simplified)

* New translations security.en.xlf (Ukrainian)

* New translations validators.en.xlf (Ukrainian)

* New translations frontend.en.xlf (Ukrainian)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (German)

* New translations messages.en.xlf (German)

* New translations messages.en.xlf (Danish)
2026-02-07 17:11:32 +01:00
Jan Böhmer
7bffe66b73 Removed Translator that became obsolete with Symfony 7.2 2026-02-07 17:11:05 +01:00
Jan Böhmer
061af28c48 Fixed phpstan issues in GenericWebProvider 2026-02-07 17:07:53 +01:00
Jan Böhmer
851055bdb4 Merge branch 'generic_webshop' 2026-02-03 23:20:17 +01:00
Jan Böhmer
7d19ed3ca8 Try to get a category from a webshop based on the breadcrumbs 2026-02-03 23:20:13 +01:00
Jan Böhmer
b48de83a32 Use brick schema to implement GenericWebProvider
This is less error prone than our own parser and also allows to parse Microdata and rdfa lite to support more webshops
2026-02-03 23:04:18 +01:00
Jan Böhmer
518953ad45 Merge branch 'master' into generic_webshop 2026-02-03 21:51:27 +01:00
Jan Böhmer
ea748dc469 Use cache.app adapter for settings content cache 2026-02-03 21:49:31 +01:00
Jan Böhmer
c027f9ab03 Updated dependencies 2026-02-03 21:48:17 +01:00
Jan Böhmer
bc28eb9473 Remove lowercase version of Makefile that causes warnings on Windows 2026-02-03 21:42:50 +01:00
Jan Böhmer
7eafa7da14 Merge branch 'feature/update-manager' 2026-02-03 21:41:44 +01:00
Jan Böhmer
1601382b41 Added translation for downgrading in progress title 2026-02-03 20:55:31 +01:00
Jan Böhmer
5ceadc8353 Use a special settings cache that lives in cache.system to ensure that it is properly cleared on cache clear 2026-02-03 20:49:25 +01:00
Jan Böhmer
36e105afa8 Merge remote-tracking branch 'Sebbeben/feature/update-manager' into feature/update-manager 2026-02-03 20:34:09 +01:00
Jan Böhmer
c34acfe523 Allow to view progress view while update is running 2026-02-03 20:34:03 +01:00
Sebastian Almberg
e83e7398a2 Improve .env comments for Update Manager settings
Clarify that 0=enabled and 1=disabled for DISABLE_WEB_UPDATES
and DISABLE_BACKUP_RESTORE environment variables.
2026-02-03 20:16:24 +01:00
Sebastian Almberg
984529bc79 Add Update Manager documentation
- Add comprehensive update_manager.md with feature overview
- Document CLI commands (partdb:update, partdb:maintenance-mode)
- Document web interface and permissions
- Add security considerations and troubleshooting
- Update console_commands.md with new commands
2026-02-03 11:55:53 +01:00
Jan Böhmer
cad5261aba Fixed phpstan issues 2026-02-02 23:26:18 +01:00
Jan Böhmer
a755287c3b Make maintenance command available under partdb:maintenance-mode to make it more consistent with other hyphen command tools 2026-02-02 23:09:52 +01:00
Jan Böhmer
9ca1834d9b Removed unused translations 2026-02-02 23:07:24 +01:00
Jan Böhmer
1a06432cec Removed custom yes and no translations 2026-02-02 22:16:26 +01:00
Jan Böhmer
58d574a33a Only use the simple maintenance page, and made this a bit more generic 2026-02-02 22:10:52 +01:00
Jan Böhmer
1adfec16e2 Added an console command to turn maintenance mode on or off 2026-02-02 21:53:55 +01:00
Jan Böhmer
903716ad62 Added missing translations 2026-02-02 21:39:01 +01:00
Jan Böhmer
427778e4eb Moved "Cant auto update panel higher" to make it more visible 2026-02-02 21:37:04 +01:00
Jan Böhmer
9b0841081b We are in development of 2.6.0 2026-02-02 21:30:02 +01:00
Jan Böhmer
f327688f0a Put update manager under /system route instead of admin 2026-02-02 21:29:07 +01:00
Jan Böhmer
0e5a73b6f4 Add nonce to inline script in progress bar 2026-02-02 21:22:06 +01:00
Jan Böhmer
d06df4410d Disable the web updater and web backup restore for now
This can become default, when there is more experience with the web updated
2026-02-02 21:18:44 +01:00
Jan Böhmer
883e3b271d Fixed git commit hash logic 2026-02-02 21:02:08 +01:00
Jan Böhmer
29a08d152a Use version info from updateChecker to be consistent 2026-02-02 20:52:42 +01:00
Jan Böhmer
2b94ff952c Use different symbol for update manager 2026-02-02 20:49:21 +01:00
Jan Böhmer
7a856bf6f1 Try to emulate nohup behavior on windows 2026-02-02 20:37:02 +01:00
Jan Böhmer
720c1e51e8 Improved UpdateExecutor 2026-02-02 20:28:17 +01:00
Jan Böhmer
1ccc3ad440 Extracted logic used by both BackupManager and UpdateExecutor to new service 2026-02-02 19:48:27 +01:00
Jan Böhmer
68ff0721ce Merged functionality from UpdateAvailableManager and UpdateChecker 2026-02-02 18:44:44 +01:00
Jan Böhmer
6dbead6d10 Centralized git logic from InstallationTypeDetector and UpdateChecker in GitVersionInfoProvider service 2026-02-02 18:18:36 +01:00
Jan Böhmer
7ff07a7ab4 Remove Content-Security-Policy for maintenance mode 2026-02-02 17:28:35 +01:00
Jan Böhmer
1bfd36ccf5 Do not automatically give existing users the right to manage updates, but include that for new databases 2026-02-02 17:04:45 +01:00
Jan Böhmer
7e486a93c9 Added missing phpdoc structure definitions 2026-02-02 17:02:01 +01:00
Jan Böhmer
599145886b Merge branch 'master' into feature/update-manager 2026-02-02 16:43:02 +01:00
Jan Böhmer
0826acbd52 Fixed phpunit tests 2026-02-01 23:11:56 +01:00
Jan Böhmer
04e8229799 Merge branch 'generic_webshop' 2026-02-01 21:35:33 +01:00
Jan Böhmer
a1396c6696 Fixed delegation logic for PartDetailDTO 2026-02-01 21:19:11 +01:00
Jan Böhmer
24f0f0d23c Added URL handling to a few more existing info providers 2026-02-01 21:18:06 +01:00
Jan Böhmer
10acc2e130 Added logic to delegate the info retrieval logic to another provider when giving an URL 2026-02-01 20:49:50 +01:00
Sebastian Almberg
47295bda29 Add unit tests for BackupManager and UpdateExecutor
Tests cover:
- BackupManager: backup directory, listing, details parsing
- UpdateExecutor: lock/unlock, maintenance mode, validation, progress
2026-02-01 19:28:15 +01:00
Sebastian Almberg
f369e14f2f Merge remote changes with PR feedback
Combined jbtronics' debug mode handling for composer install
with our yarn install/build steps and BackupManager refactoring.
2026-02-01 19:23:07 +01:00
Sebastian Almberg
10c192edd1 Address PR feedback: add yarn build, env vars, and BackupManager
Changes based on maintainer feedback from PR #1217:

1. Add yarn install/build steps to update process
   - Added yarn availability check in validateUpdatePreconditions
   - Added yarn install and yarn build steps after composer install
   - Added yarn rebuild to rollback process
   - Updated total steps count from 12 to 14

2. Add environment variables to disable web features
   - DISABLE_WEB_UPDATES: Completely disable web-based updates
   - DISABLE_BACKUP_RESTORE: Disable backup restore from web UI
   - Added checks in controller and template

3. Extract BackupManager service
   - New service handles backup creation, listing, details, and restoration
   - UpdateExecutor now delegates backup operations to BackupManager
   - Cleaner separation of concerns for future reuse

4. Merge upstream/master and resolve translation conflicts
   - Added Conrad info provider and generic web provider translations
   - Kept Update Manager translations
2026-02-01 19:17:22 +01:00
Sebastian Almberg
6b27f3aa14 Merge upstream/master and resolve translation conflict
Merged new Conrad info provider and generic web provider translations
from upstream while keeping Update Manager translations.
2026-02-01 19:07:15 +01:00
Jan Böhmer
79f88c66d6 Merge branch 'generic_webshop' 2026-02-01 18:26:30 +01:00
Jan Böhmer
47c7ee9f07 Allow to extract parameters form additionalProperty JSONLD data 2026-02-01 18:24:46 +01:00
Jan Böhmer
909cab0044 Added an web page to quickly add a new part from a web URL 2026-02-01 18:18:58 +01:00
Jan Böhmer
722eb7ddab Added settings and docs for the generic Web info provider 2026-02-01 17:47:04 +01:00
Jan Böhmer
071f6f8591 Return an empty array if no URL is provider to the Generic Web URL provider 2026-02-01 17:34:08 +01:00
Jan Böhmer
7feba634b8 Hadle if offers are nested and images are ImageObjects in JSON+LD 2026-02-01 17:20:13 +01:00
Jan Böhmer
1213f82cdf Fix if canonical URL is relative 2026-02-01 17:11:41 +01:00
Jan Böhmer
d868225260 Properly parse JSONLD product data if it is in an array with others 2026-02-01 17:06:38 +01:00
Jan Böhmer
52be548170 Add https:// if not existing 2026-02-01 16:55:52 +01:00
Jan Böhmer
73dbe64a83 Allow to extract prices form an Amazon page 2026-02-01 16:51:26 +01:00
Jan Böhmer
b89e878871 Allow to rudimentary parse product pages, even if they do not contain JSON-LD data 2026-02-01 16:39:19 +01:00
Jan Böhmer
14981200c8 Started implementing a generic web provider which uses JSONLD data provided by a webshop page 2026-02-01 14:35:58 +01:00
Jan Böhmer
8aadc0bb53 Highlight the scanned part lot when scanning an barcode
Fixed issue #968
2026-02-01 13:13:26 +01:00
Jan Böhmer
0eba4738ed Fixed composer.json formatting 2026-01-31 23:38:38 +01:00
Jan Böhmer
a78ca675b3 Install dev dependencies when updating a debug mode instance
Otherwise we run into an error message that web profiler does not exist
2026-01-31 23:36:09 +01:00
Jan Böhmer
6ac7a42cca Require ext-zip in composer.json 2026-01-31 23:33:39 +01:00
Niklas
a355bda9da add supplier SPN linking for BOM import (#1209)
* feat: add supplier SPN lookup for BOM import

Add automatic part linking via supplier part numbers (SPNs) in the
BOM importer. When a Part-DB ID is not provided, the importer now
searches for existing parts by matching supplier SPNs from the CSV
with orderdetail records in the database.

This allows automatic part linking when KiCad schematic BOMs contain
supplier information like LCSC SPN, Mouser SPN, etc., improving the
import workflow for users who track parts by supplier part numbers.

* add tests for BOM import with supplier SPN handling
2026-01-31 22:37:43 +01:00
Jan Böhmer
584643d4ca Fixed phpstan issue 2026-01-31 22:21:59 +01:00
Jan Böhmer
2534c84039 Updated dependencies 2026-01-31 22:16:50 +01:00
Jan Böhmer
ed39710f7f Merge branch 'conrad_provider'
Makes PR #1211 obsolete
2026-01-31 22:12:19 +01:00
Jan Böhmer
df3f069a76 Added translations for conrad settings 2026-01-31 22:11:50 +01:00
Jan Böhmer
c0babfa401 Added docs for the conrad info provider 2026-01-31 22:03:35 +01:00
Jan Böhmer
cd7cd6cdd3 Allow to retrieve (short) category info from Conrad provider 2026-01-31 21:57:05 +01:00
Jan Böhmer
6d224a4a9f Allow to filter for languages in conrad attachments 2026-01-31 21:49:43 +01:00
Jan Böhmer
fa04fface3 Fixed bug with parameter parsing 2026-01-31 21:45:27 +01:00
Jan Böhmer
2f8553303d Use better fields for determine the product name 2026-01-31 21:39:34 +01:00
Jan Böhmer
f168b2a83c Reordered ConradShopIDs 2026-01-31 21:30:15 +01:00
Jan Böhmer
98937974c9 Allow to query price infos from conrad 2026-01-31 21:15:35 +01:00
Jan Böhmer
6f4dad98d9 Use parameter parsing logic from PR #1211 to handle multi parameters fine 2026-01-31 19:04:25 +01:00
Jan Böhmer
22cf04585b Allow to retrieve datasheets from conrad 2026-01-31 18:57:00 +01:00
Jan Böhmer
6628333675 Properly handle danish and non-german swiss shop 2026-01-31 18:43:59 +01:00
Sebastian Almberg
fa4ae6345c Add Update Manager screenshot for PR 2026-01-30 23:36:08 +01:00
Sebastian Almberg
1637fd63f4 Add backup restore feature
- Add restoreBackup() method to UpdateExecutor with full restore workflow
- Add getBackupDetails() to retrieve backup metadata and contents info
- Add restore controller routes (backup details API, restore action)
- Add restore button to backups table in UI
- Create backup_restore_controller.js Stimulus controller for confirmation
- Add translation strings for restore feature

The restore process:
1. Acquires lock and enables maintenance mode
2. Extracts backup to temp directory
3. Restores database (MySQL/PostgreSQL SQL or SQLite file)
4. Optionally restores config files and attachments
5. Clears and warms cache
6. Disables maintenance mode

Fix backup restore database import

The restore feature was using a non-existent doctrine:database:import
command. Now properly uses mysql/psql commands directly to import
database dumps.

Changes:
- Add EntityManagerInterface dependency to UpdateExecutor
- Use mysql command with shell input redirection for MySQL restore
- Use psql -f command for PostgreSQL restore
- Properly handle database connection parameters
- Add error handling for failed imports
2026-01-30 23:24:48 +01:00
Sebastian Almberg
0bfbbc961d Fix update confirmation dialog not blocking form submission
The previous implementation used inline onsubmit handlers with return
confirmVersionChange(...), which could fail silently if any JavaScript
error occurred on the page, causing the form to submit without confirmation.

Fixes:
- Use event.preventDefault() FIRST to ensure form never submits by default
- Use DOMContentLoaded event listeners instead of inline handlers
- Properly escape translation strings using json_encode filter
- Wrap in IIFE with 'use strict' for better error handling
- Use data-attributes to identify forms and pass isDowngrade state

Fix DOMContentLoaded race condition in update form handlers

The event listener was not attaching if DOMContentLoaded had already
fired by the time the script executed. Now checks document.readyState
and attaches handlers immediately if DOM is already ready.

Added console.log statements to help debug form handler attachment.

Use Stimulus controller for update confirmation dialogs

The inline script was blocked by Content Security Policy (CSP).
Stimulus controllers are bundled with webpack and properly allowed by CSP.

- Create update_confirm_controller.js Stimulus controller
- Remove inline script from template
- Pass translation strings via data-* attributes
2026-01-30 23:24:48 +01:00
Sebastian Almberg
97e3b0aa09 Add downgrade warning for versions without Update Manager
When downgrading to versions before v2.6.0, show a warning that the
Update Manager will not be available in older versions and that future
updates will need to be done manually via command line.
2026-01-30 21:56:14 +01:00
Sebastian Almberg
87352ca6f7 Add manage_updates permission schema migration
- Bump permission schema to version 4
- Add upgradeSchemaToVersion4 for manage_updates permission
  - Grants manage_updates to users who have both show_updates and server_infos
- Fix ZIP_RELEASE installation type: set supportsAutoUpdate to false
  (ZIP update not yet implemented)
- Improve update instructions for ZIP installations
2026-01-30 21:46:27 +01:00
Sebastian Almberg
42fe781ef8 Add Update Manager for automated Part-DB updates
This feature adds a comprehensive Update Manager similar to Mainsail's
update system, allowing administrators to update Part-DB directly from
the web interface.

Features:
- Web UI at /admin/update-manager showing current and available versions
- Support for Git-based installations with automatic update execution
- Maintenance mode during updates to prevent user access
- Automatic database backup before updates
- Git rollback points for recovery (tags created before each update)
- Progress tracking with real-time status updates
- Update history and log viewing
- Downgrade support with appropriate UI messaging
- CLI command `php bin/console partdb:update` for server-side updates

New files:
- UpdateManagerController: Handles all web UI routes
- UpdateCommand: CLI command for running updates
- UpdateExecutor: Core update execution logic with safety mechanisms
- UpdateChecker: GitHub API integration for version checking
- InstallationTypeDetector: Detects installation type (Git/Docker/ZIP)
- MaintenanceModeSubscriber: Blocks user access during maintenance
- UpdateExtension: Twig functions for update notifications

UI improvements:
- Update notification in navbar for admins when update available
- Confirmation dialogs for update/downgrade actions
- Downgrade-specific text throughout the interface
- Progress page with auto-refresh
2026-01-30 21:36:33 +01:00
Jan Böhmer
3ed62f5cee Allow to retrieve parameters from conrad 2026-01-26 23:18:32 +01:00
Jan Böhmer
7ab33c859b Implemented basic functionality to search and retrieve part details 2026-01-26 23:07:01 +01:00
Jan Böhmer
705e71f1eb Started working on a conrad provider 2026-01-26 20:58:20 +01:00
Jan Böhmer
ae4c0786b2 Bumped to version 2.5.1 2026-01-25 21:38:49 +01:00
Niklas
3aad70934b Support dynamic supplier SPNs in BOM import comments (#1208)
* Fix: Use correct field name for LCSC supplier part numbers in BOM import

The field mapping system uses 'LCSC SPN' as the target field name for LCSC
supplier part numbers (following the pattern SupplierName + ' SPN'), but the
code in parseKiCADSchematic() was incorrectly checking for 'LCSC'.

This caused LCSC supplier part numbers to be silently ignored and not included
in the BOM entry comments during schematic import.

Changed isset($mapped_entry['LCSC']) to isset($mapped_entry['LCSC SPN']) to
match the actual field name produced by the field mapping system.

* regression test: check for LCSC SPN in comment

* Support dynamic supplier SPNs in BOM import comments

Replace hardcoded LCSC SPN handling with dynamic supplier lookup to support all configured suppliers in BOM import. This allows any supplier defined in
Part-DB to have their SPN fields recognized and included in the BOM entry
comments during BOM import.

* Optimize BOM import by only calculating supplier SPN keys once
2026-01-25 21:32:14 +01:00
Jan Böhmer
e15d12c0bf Merge remote-tracking branch 'origin/l10n_master' 2026-01-25 21:27:51 +01:00
Copilot
ff7fa67682 Install Yarn via npm instead of Debian packages in Dockerfiles (#1207)
* Initial plan

* Change yarn installation from Debian packages to npm in both Dockerfiles

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
2026-01-25 21:25:08 +01:00
Jan Böhmer
2b723e05ff New translations frontend.en.xlf (English) 2026-01-25 21:16:04 +01:00
Jan Böhmer
a8d2204c7f New translations validators.en.xlf (German) 2026-01-25 21:15:51 +01:00
Jan Böhmer
29050178bd New translations messages.en.xlf (German) 2026-01-25 21:15:50 +01:00
Jan Böhmer
af61772c88 Revert "Fixed frankenphp docker build"
This reverts commit b91cd44926.
2026-01-25 20:31:10 +01:00
Jan Böhmer
b91cd44926 Fixed frankenphp docker build 2026-01-25 20:15:29 +01:00
Jan Böhmer
c476c98d56 Added clear button to optional part select fields
Fixes #1156
2026-01-25 19:12:27 +01:00
Jan Böhmer
fe458b7ff1 When uploading a file, automatically determine the best fitting attachment type 2026-01-25 18:41:11 +01:00
Jan Böhmer
7b8f3aaf62 New translations messages.en.xlf (English) 2026-01-25 18:23:26 +01:00
Jan Böhmer
d93dfd577e Fail more gracefully when an error occurs in the info providers 2026-01-25 18:22:47 +01:00
Jan Böhmer
4095d0fd49 New translations frontend.en.xlf (Danish) 2026-01-25 10:50:30 +01:00
Jan Böhmer
6d3197497e New translations security.en.xlf (Danish) 2026-01-25 10:50:26 +01:00
Jan Böhmer
f438a8b4cd New translations validators.en.xlf (Danish) 2026-01-25 10:50:25 +01:00
Jan Böhmer
56fa2a9396 Updated yarn dependencies 2026-01-25 00:51:57 +01:00
Jan Böhmer
3975a3ba61 Updated composer dependencies
We can now use the most recent symfony property-info versions now again, as the bug was fixed in upstream
2026-01-25 00:51:00 +01:00
Jan Böhmer
aa9aedc5fd Prevent the extra column of the log data tables to be ordered
This makes not much sense because its JSON data under the hood, and PostgreSQL errors when trying to do it.
2026-01-25 00:38:11 +01:00
Jan Böhmer
766ba07105 Properly disable the id search by default
Follow up on PR #1184
2026-01-18 23:48:04 +01:00
Jan Böhmer
d0b827c2c3 Do not use the wrong language for trees when no user is logged in 2026-01-18 23:44:11 +01:00
Jan Böhmer
cd7dbd5f7b Bumped version to 2.5.0 2026-01-18 22:59:35 +01:00
Jan Böhmer
8efbca798a Merge remote-tracking branch 'origin/master' 2026-01-18 22:53:40 +01:00
Jan Böhmer
dd6c20780b Ensure that the ids passed to DBElementRepository::findByIDInMatchingOrder are all ints
This might help to diagnose #1188
2026-01-18 22:53:37 +01:00
Lukas Runge
af81e15ef2 Set "Excluded from sim" to false by default for new categories to avoid annoying symbol at kicad parts. 2026-01-18 22:35:37 +01:00
Jan Böhmer
09cc2ba8ff Use requestSubmit() in form cleanup controller to avoid CSFR issues
See #1191
2026-01-18 22:24:17 +01:00
swdee
131023da67 change barcode scan form to use requestSubmit() to fix CSRF token not being generated on submission (#1191) 2026-01-18 22:14:17 +01:00
Jan Böhmer
4636aa4e0d New translations frontend.en.xlf (Hungarian) 2026-01-18 22:00:38 +01:00
Jan Böhmer
006cfd7b5d New translations frontend.en.xlf (German) 2026-01-18 22:00:37 +01:00
Jan Böhmer
86f53b2956 Update Crowdin configuration file 2026-01-18 21:58:54 +01:00
Jan Böhmer
1923abecdf New translations messages.en.xlf (English) 2026-01-18 19:50:33 +01:00
Jan Böhmer
3e071f2b74 New translations messages.en.xlf (English) 2026-01-04 22:00:55 +01:00
Jan Böhmer
89322d329c New translations messages.en.xlf (English) 2026-01-04 18:00:49 +01:00
397 changed files with 19816 additions and 40439 deletions

View File

@@ -12,7 +12,7 @@ opcache.max_accelerated_files = 20000
opcache.memory_consumption = 256
opcache.enable_file_override = 1
memory_limit = 256M
memory_limit = 512M
upload_max_filesize=256M
post_max_size=300M
post_max_size=300M

View File

@@ -1,4 +1,3 @@
worker {
file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}

11
.env
View File

@@ -59,6 +59,17 @@ ERROR_PAGE_ADMIN_EMAIL=''
# If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them...
ERROR_PAGE_SHOW_HELP=1
###################################################################################
# Update Manager settings
###################################################################################
# Disable web-based updates from the Update Manager UI (0=enabled, 1=disabled).
# When disabled, use the CLI command "php bin/console partdb:update" instead.
DISABLE_WEB_UPDATES=1
# Disable backup restore from the Update Manager UI (0=enabled, 1=disabled).
# Restoring backups is a destructive operation that could overwrite your database.
DISABLE_BACKUP_RESTORE=1
###################################################################################
# SAML Single sign on-settings

View File

@@ -15,8 +15,20 @@ on:
- 'v*.*.*-**'
jobs:
docker:
runs-on: ubuntu-latest
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
platform-slug: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
platform-slug: arm64
- platform: linux/arm/v7
runner: ubuntu-24.04-arm
platform-slug: armv7
runs-on: ${{ matrix.runner }}
steps:
-
name: Checkout
@@ -32,13 +44,12 @@ jobs:
# Mark the image build from master as latest (as we dont have really releases yet)
tags: |
type=edge,branch=master
type=ref,event=branch,
type=ref,event=tag,
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
type=ref,event=pr
labels: |
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
@@ -49,12 +60,10 @@ jobs:
org.opencontainers.image.source=https://github.com/Part-DB/Part-DB-symfony
org.opencontainers.image.authors=Jan Böhmer
org.opencontainers.licenses=AGPL-3.0-or-later
# Disable automatic 'latest' tag in build jobs - it will be created in merge job
flavor: |
latest=false
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -67,13 +76,85 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=jbtronics/part-db1,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
-
name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
if: github.event_name != 'pull_request'
steps:
-
name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: |
jbtronics/part-db1
tags: |
type=edge,branch=master
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=pr
-
name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'jbtronics/part-db1@sha256:%s ' *)
-
name: Inspect image
run: |
docker buildx imagetools inspect jbtronics/part-db1:${{ steps.docker_meta.outputs.version }}

View File

@@ -15,8 +15,20 @@ on:
- 'v*.*.*-**'
jobs:
docker:
runs-on: ubuntu-latest
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
platform-slug: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
platform-slug: arm64
- platform: linux/arm/v7
runner: ubuntu-24.04-arm
platform-slug: armv7
runs-on: ${{ matrix.runner }}
steps:
-
name: Checkout
@@ -32,13 +44,12 @@ jobs:
# Mark the image build from master as latest (as we dont have really releases yet)
tags: |
type=edge,branch=master
type=ref,event=branch,
type=ref,event=tag,
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
type=ref,event=pr
labels: |
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
@@ -49,12 +60,10 @@ jobs:
org.opencontainers.image.source=https://github.com/Part-DB/Part-DB-server
org.opencontainers.image.authors=Jan Böhmer
org.opencontainers.licenses=AGPL-3.0-or-later
# Disable automatic 'latest' tag in build jobs - it will be created in merge job
flavor: |
latest=false
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -67,14 +76,86 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile-frankenphp
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=partdborg/part-db,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
-
name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
if: github.event_name != 'pull_request'
steps:
-
name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: |
partdborg/part-db
tags: |
type=edge,branch=master
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=pr
-
name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'partdborg/part-db@sha256:%s ' *)
-
name: Inspect image
run: |
docker buildx imagetools inspect partdborg/part-db:${{ steps.docker_meta.outputs.version }}

View File

@@ -1,15 +1,75 @@
# syntax=docker/dockerfile:1
ARG BASE_IMAGE=debian:bookworm-slim
ARG PHP_VERSION=8.4
ARG NODE_VERSION=22
# Node.js build stage for building frontend assets
# Use native platform for build stage as it's platform-independent
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS node-builder
ARG TARGETARCH
WORKDIR /app
# Install composer and minimal PHP for running Symfony commands
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Use BuildKit cache mounts for apt in builder stage
RUN --mount=type=cache,id=apt-cache-node-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-node-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get install -y --no-install-recommends \
php-cli \
php-xml \
php-mbstring \
unzip \
git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Copy composer files and install dependencies (needed for Symfony UX assets)
COPY composer.json composer.lock symfony.lock ./
# Use BuildKit cache for Composer downloads
RUN --mount=type=cache,id=composer-cache,target=/root/.cache/composer \
composer install --no-scripts --no-autoloader --no-dev --prefer-dist --ignore-platform-reqs
# Copy all application files needed for cache warmup and webpack build
COPY .env* ./
COPY bin ./bin
COPY config ./config
COPY src ./src
COPY translations ./translations
COPY public ./public
COPY assets ./assets
COPY webpack.config.js ./
# Generate autoloader
RUN composer dump-autoload
# Create required directories for cache warmup
RUN mkdir -p var/cache var/log uploads public/media
# Dump translations, which we need for cache warmup
RUN php bin/console cache:warmup -n --env=prod 2>&1
# Copy package files and install node dependencies
COPY package.json yarn.lock ./
# Use BuildKit cache for yarn/npm
RUN --mount=type=cache,id=yarn-cache,target=/root/.cache/yarn \
--mount=type=cache,id=npm-cache,target=/root/.npm \
yarn install --network-timeout 600000
# Build the assets
RUN yarn build
# Clean up
RUN yarn cache clean && rm -rf node_modules/
# Base stage for PHP
FROM ${BASE_IMAGE} AS base
ARG PHP_VERSION
ARG TARGETARCH
# Install needed dependencies for PHP build
#RUN apt-get update && apt-get install -y pkg-config curl libcurl4-openssl-dev libicu-dev \
# libpng-dev libjpeg-dev libfreetype6-dev gnupg zip libzip-dev libjpeg62-turbo-dev libonig-dev libxslt-dev libwebp-dev vim \
# && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get -y install \
# Use BuildKit cache mounts for apt in base stage
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get -y install \
apt-transport-https \
lsb-release \
ca-certificates \
@@ -39,21 +99,10 @@ RUN apt-get update && apt-get -y install \
gpg \
sudo \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* \
# Create workdir and set permissions if directory does not exists
&& mkdir -p /var/www/html \
&& chown -R www-data:www-data /var/www/html \
# delete the "index.html" that installing Apache drops in here
&& rm -rvf /var/www/html/*
# Install node and yarn
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get update && apt-get install -y \
nodejs \
yarn \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*
# Install composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
@@ -67,14 +116,12 @@ ENV APACHE_ENVVARS=$APACHE_CONFDIR/envvars
# : ${APACHE_RUN_USER:=www-data}
# export APACHE_RUN_USER
# so that they can be overridden at runtime ("-e APACHE_RUN_USER=...")
RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS"; \
set -eux; . "$APACHE_ENVVARS"; \
\
# logs should go to stdout / stderr
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"; \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log"; \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"; \
chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR";
RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS" && \
set -eux; . "$APACHE_ENVVARS" && \
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log" && \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log" && \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log" && \
chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR"
# ---
@@ -143,7 +190,6 @@ COPY --chown=www-data:www-data . .
# Setup apache2
RUN a2dissite 000-default.conf && \
a2ensite symfony.conf && \
# Enable php-fpm
a2enmod proxy_fcgi setenvif && \
a2enconf php${PHP_VERSION}-fpm && \
a2enconf docker-php && \
@@ -151,12 +197,13 @@ RUN a2dissite 000-default.conf && \
# Install composer and yarn dependencies for Part-DB
USER www-data
RUN composer install -a --no-dev && \
# Use BuildKit cache for Composer when running as www-data by setting COMPOSER_CACHE_DIR
RUN --mount=type=cache,id=composer-cache,target=/tmp/.composer-cache \
COMPOSER_CACHE_DIR=/tmp/.composer-cache composer install -a --no-dev && \
composer clear-cache
RUN yarn install --network-timeout 600000 && \
yarn build && \
yarn cache clean && \
rm -rf node_modules/
# Copy built frontend assets from node-builder stage
COPY --from=node-builder --chown=www-data:www-data /app/public/build ./public/build
# Use docker env to output logs to stdout
ENV APP_ENV=docker
@@ -168,10 +215,12 @@ USER root
RUN sed -i "s/PHP_VERSION/${PHP_VERSION}/g" ./.docker/partdb-entrypoint.sh
# Copy entrypoint and apache2-foreground to /usr/local/bin and make it executable
RUN install ./.docker/partdb-entrypoint.sh /usr/local/bin && \
install ./.docker/apache2-foreground /usr/local/bin
# Convert CRLF -> LF and install entrypoint scripts with executable mode
RUN sed -i 's/\r$//' ./.docker/partdb-entrypoint.sh ./.docker/apache2-foreground && \
install -m 0755 ./.docker/partdb-entrypoint.sh /usr/local/bin/ && \
install -m 0755 ./.docker/apache2-foreground /usr/local/bin/
ENTRYPOINT ["partdb-entrypoint.sh"]
CMD ["apache2-foreground"]
CMD ["/usr/local/bin/apache2-foreground"]
# https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop
STOPSIGNAL SIGWINCH

View File

@@ -1,6 +1,72 @@
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
ARG NODE_VERSION=22
RUN apt-get update && apt-get -y install \
# Node.js build stage for building frontend assets
# Use native platform for build stage as it's platform-independent
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS node-builder
ARG TARGETARCH
WORKDIR /app
# Install composer and minimal PHP for running Symfony commands
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Use BuildKit cache mounts for apt in builder stage
RUN --mount=type=cache,id=apt-cache-node-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-node-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get install -y --no-install-recommends \
php-cli \
php-xml \
php-mbstring \
unzip \
git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Copy composer files and install dependencies (needed for Symfony UX assets)
COPY composer.json composer.lock symfony.lock ./
# Use BuildKit cache for Composer downloads
RUN --mount=type=cache,id=composer-cache,target=/root/.cache/composer \
composer install --no-scripts --no-autoloader --no-dev --prefer-dist --ignore-platform-reqs
# Copy all application files needed for cache warmup and webpack build
COPY .env* ./
COPY bin ./bin
COPY config ./config
COPY src ./src
COPY translations ./translations
COPY public ./public
COPY assets ./assets
COPY webpack.config.js ./
# Generate autoloader
RUN composer dump-autoload
# Create required directories for cache warmup
RUN mkdir -p var/cache var/log uploads public/media
# Dump translations, which we need for cache warmup
RUN php bin/console cache:warmup -n --env=prod 2>&1
# Copy package files and install node dependencies
COPY package.json yarn.lock ./
# Use BuildKit cache for yarn/npm
RUN --mount=type=cache,id=yarn-cache,target=/root/.cache/yarn \
--mount=type=cache,id=npm-cache,target=/root/.npm \
yarn install --network-timeout 600000
# Build the assets
RUN yarn build
# Clean up
RUN yarn cache clean && rm -rf node_modules/
# FrankenPHP base stage
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
ARG TARGETARCH
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get -y install \
curl \
ca-certificates \
mariadb-client \
@@ -13,34 +79,6 @@ RUN apt-get update && apt-get -y install \
zip \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*;
RUN set -eux; \
# Prepare keyrings directory
mkdir -p /etc/apt/keyrings; \
\
# Import Yarn GPG key
curl -fsSL https://dl.yarnpkg.com/debian/pubkey.gpg \
| tee /etc/apt/keyrings/yarn.gpg >/dev/null; \
chmod 644 /etc/apt/keyrings/yarn.gpg; \
\
# Add Yarn repo with signed-by
echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian stable main" \
| tee /etc/apt/sources.list.d/yarn.list; \
\
# Run NodeSource setup script (unchanged)
curl -sL https://deb.nodesource.com/setup_22.x | bash -; \
\
# Install Node.js + Yarn
apt-get update; \
apt-get install -y --no-install-recommends \
nodejs \
yarn; \
\
# Cleanup
apt-get -y autoremove; \
apt-get clean autoclean; \
rm -rf /var/lib/apt/lists/*
# Install PHP
RUN set -eux; \
install-php-extensions \
@@ -86,14 +124,11 @@ COPY --link . ./
RUN set -eux; \
mkdir -p var/cache var/log; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync;
RUN yarn install --network-timeout 600000 && \
yarn build && \
yarn cache clean && \
rm -rf node_modules/
# Copy built frontend assets from node-builder stage
COPY --from=node-builder /app/public/build ./public/build
# Use docker env to output logs to stdout
ENV APP_ENV=docker
@@ -112,8 +147,8 @@ VOLUME ["/var/www/html/uploads", "/var/www/html/public/media"]
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
# See https://caddyserver.com/docs/conventions#file-locations for details
ENV XDG_CONFIG_HOME /config
ENV XDG_DATA_HOME /data
ENV XDG_CONFIG_HOME=/config
ENV XDG_DATA_HOME=/data
EXPOSE 80
EXPOSE 443

View File

@@ -1 +1 @@
2.4.0
2.7.1

View File

@@ -0,0 +1,55 @@
/*
* 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/>.
*/
import { Controller } from '@hotwired/stimulus';
/**
* Stimulus controller for backup restore confirmation dialogs.
* Shows a confirmation dialog with backup details before allowing restore.
*/
export default class extends Controller {
static values = {
filename: { type: String, default: '' },
date: { type: String, default: '' },
confirmTitle: { type: String, default: 'Restore Backup' },
confirmMessage: { type: String, default: 'Are you sure you want to restore from this backup?' },
confirmWarning: { type: String, default: 'This will overwrite your current database. This action cannot be undone!' },
};
connect() {
this.element.addEventListener('submit', this.handleSubmit.bind(this));
}
handleSubmit(event) {
// Always prevent default first
event.preventDefault();
// Build confirmation message
const message = this.confirmTitleValue + '\n\n' +
'Backup: ' + this.filenameValue + '\n' +
'Date: ' + this.dateValue + '\n\n' +
this.confirmMessageValue + '\n\n' +
'⚠️ ' + this.confirmWarningValue;
// Only submit if user confirms
if (confirm(message)) {
this.element.submit();
}
}
}

View File

@@ -21,6 +21,7 @@ import {Controller} from "@hotwired/stimulus";
import * as bootbox from "bootbox";
import "../../css/components/bootbox_extensions.css";
import accept from "attr-accept";
export default class extends Controller {
static values = {
@@ -73,15 +74,33 @@ export default class extends Controller {
const newElementStr = this.htmlDecode(prototype.replace(regex, this.generateUID()));
let ret = null;
//Insert new html after the last child element
//If the table has a tbody, insert it there
//Afterwards return the newly created row
if(targetTable.tBodies[0]) {
targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr);
return targetTable.tBodies[0].lastElementChild;
ret = targetTable.tBodies[0].lastElementChild;
} else { //Otherwise just insert it
targetTable.insertAdjacentHTML('beforeend', newElementStr);
return targetTable.lastElementChild;
ret = targetTable.lastElementChild;
}
//Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it
targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true}));
this.focusNumberInput(ret);
return ret;
}
focusNumberInput(element) {
const fields = element.querySelectorAll("input[type=number]");
//Focus the first available number input field to open the numeric keyboard on mobile devices
if(fields.length > 0) {
fields[0].focus();
}
}
@@ -112,6 +131,33 @@ export default class extends Controller {
dataTransfer.items.add(file);
rowInput.files = dataTransfer.files;
//Check the file extension and find the corresponding attachment type based on the data-filetype_filter attribute
const attachmentTypeSelect = newElement.querySelector("select");
if (attachmentTypeSelect) {
let foundMatch = false;
for (let j = 0; j < attachmentTypeSelect.options.length; j++) {
const option = attachmentTypeSelect.options[j];
//skip disabled options
if (option.disabled) {
continue;
}
const filter = option.getAttribute('data-filetype_filter');
if (filter) {
if (accept({name: file.name, type: file.type}, filter)) {
attachmentTypeSelect.value = option.value;
foundMatch = true;
break;
}
} else { //If no filter is set, chose this option until we find a better match
if (!foundMatch) {
attachmentTypeSelect.value = option.value;
foundMatch = true;
}
}
}
}
}
});
@@ -189,4 +235,4 @@ export default class extends Controller {
del();
}
}
}
}

View File

@@ -108,11 +108,19 @@ export default class extends Controller {
const raw_order = saved_state.order;
settings.initial_order = raw_order.map((order) => {
//Skip if direction is empty, as this is the default, otherwise datatables server is confused when the order is sent in the request, but the initial order is set to an empty direction
if (order[1] === '') {
return null;
}
return {
column: order[0],
dir: order[1]
}
});
//Remove null values from the initial_order array
settings.initial_order = settings.initial_order.filter(order => order !== null);
}
let options = {

View File

@@ -18,7 +18,7 @@ export default class extends Controller {
let settings = {
allowEmptyOption: true,
plugins: ['dropdown_input'],
plugins: ['dropdown_input', this.element.required ? null : 'clear_button'],
searchField: ["name", "description", "category", "footprint"],
valueField: "id",
labelField: "name",

View File

@@ -62,6 +62,6 @@ export default class extends Controller {
element.disabled = true;
}
form.submit();
form.requestSubmit();
}
}
}

View File

@@ -70,6 +70,6 @@ export default class extends Controller {
//Put our decoded Text into the input box
document.getElementById('scan_dialog_input').value = decodedText;
//Submit form
document.getElementById('scan_dialog_form').submit();
document.getElementById('scan_dialog_form').requestSubmit();
}
}
}

View File

@@ -0,0 +1,27 @@
import {Controller} from "@hotwired/stimulus";
import {Modal} from "bootstrap";
export default class extends Controller
{
connect() {
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
}
_handleModalOpen(event) {
// Button that triggered the modal
const button = event.relatedTarget;
const amountInput = this.element.querySelector('input[name="amount"]');
// Extract info from button attributes
const lotID = button.getAttribute('data-lot-id');
const lotAmount = button.getAttribute('data-lot-amount');
//Find the expected amount field and set the value to the lot amount
const expectedAmountInput = this.element.querySelector('#stocktake-modal-expected-amount');
expectedAmountInput.textContent = lotAmount;
//Set the action and lotID inputs in the form
this.element.querySelector('input[name="lot_id"]').setAttribute('value', lotID);
}
}

View File

@@ -5,6 +5,7 @@ export default class extends Controller
{
connect() {
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
this.element.addEventListener('shown.bs.modal', event => this._handleModalShown(event));
}
_handleModalOpen(event) {
@@ -61,4 +62,8 @@ export default class extends Controller
amountInput.setAttribute('max', lotAmount);
}
}
_handleModalShown(event) {
this.element.querySelector('input[name="amount"]').focus();
}
}

View File

@@ -0,0 +1,81 @@
/*
* 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/>.
*/
import { Controller } from '@hotwired/stimulus';
/**
* Stimulus controller for update/downgrade confirmation dialogs.
* Intercepts form submission and shows a confirmation dialog before proceeding.
*/
export default class extends Controller {
static values = {
isDowngrade: { type: Boolean, default: false },
targetVersion: { type: String, default: '' },
confirmUpdate: { type: String, default: 'Are you sure you want to update Part-DB?' },
confirmDowngrade: { type: String, default: 'Are you sure you want to downgrade Part-DB?' },
downgradeWarning: { type: String, default: 'WARNING: This version does not include the Update Manager.' },
minUpdateManagerVersion: { type: String, default: '2.6.0' },
};
connect() {
this.element.addEventListener('submit', this.handleSubmit.bind(this));
}
handleSubmit(event) {
// Always prevent default first
event.preventDefault();
const targetClean = this.targetVersionValue.replace(/^v/, '');
let message;
if (this.isDowngradeValue) {
// Check if downgrading to a version without Update Manager
if (this.compareVersions(targetClean, this.minUpdateManagerVersionValue) < 0) {
message = this.confirmDowngradeValue + '\n\n⚠ ' + this.downgradeWarningValue;
} else {
message = this.confirmDowngradeValue;
}
} else {
message = this.confirmUpdateValue;
}
// Only submit if user confirms
if (confirm(message)) {
// Remove the event listener to prevent infinite loop, then submit
this.element.removeEventListener('submit', this.handleSubmit.bind(this));
this.element.submit();
}
}
/**
* Compare two version strings (e.g., "2.5.0" vs "2.6.0")
* Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2
*/
compareVersions(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 < p2) return -1;
if (p1 > p2) return 1;
}
return 0;
}
}

View File

@@ -125,3 +125,25 @@ Classes for Datatables export
.export-helper{
display: none;
}
/**********************************************************
* Table row highlighting tools
***********************************************************/
.row-highlight {
box-shadow: 0 4px 15px rgba(0,0,0,0.20); /* Adds depth */
position: relative;
z-index: 1; /* Ensures the shadow overlaps other rows */
border-left: 5px solid var(--bs-primary); /* Adds a vertical accent bar */
}
@keyframes pulse-highlight {
0% { outline: 2px solid transparent; }
50% { outline: 2px solid var(--bs-primary); }
100% { outline: 2px solid transparent; }
}
.row-pulse {
animation: pulse-highlight 1s ease-in-out;
animation-iteration-count: 3;
}

View File

@@ -56,7 +56,8 @@ class TristateHelper {
document.addEventListener("turbo:load", listener);
document.addEventListener("turbo:render", listener);
document.addEventListener("collection:elementAdded", listener);
}
}
export default new TristateHelper();
export default new TristateHelper();

View File

@@ -198,6 +198,7 @@ class WebauthnTFA {
{
const resultField = document.getElementById('_auth_code');
resultField.value = JSON.stringify(data)
//requestSubmit() do not work here, probably because the submit is considered invalid. But as we do not use CSFR tokens, it should be fine.
form.submit();
}
@@ -232,4 +233,4 @@ class WebauthnTFA {
}
}
window.webauthnTFA = new WebauthnTFA();
window.webauthnTFA = new WebauthnTFA();

View File

@@ -11,12 +11,14 @@
"ext-intl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-zip": "*",
"amphp/http-client": "^5.1",
"api-platform/doctrine-orm": "^4.1",
"api-platform/json-api": "^4.0.0",
"api-platform/symfony": "^4.0.0",
"beberlei/doctrineextensions": "^1.2",
"brick/math": "^0.13.1",
"brick/math": "^0.14.8",
"brick/schema": "^0.2.0",
"composer/ca-bundle": "^1.5",
"composer/package-versions-deprecated": "^1.11.99.5",
"doctrine/data-fixtures": "^2.0.0",
@@ -26,7 +28,7 @@
"doctrine/orm": "^3.2.0",
"dompdf/dompdf": "^3.1.2",
"gregwar/captcha-bundle": "^2.1.0",
"hshn/base64-encoded-file": "^5.0",
"hshn/base64-encoded-file": "^6.0",
"jbtronics/2fa-webauthn": "^3.0.0",
"jbtronics/dompdf-font-loader-bundle": "^1.0.0",
"jbtronics/settings-bundle": "^3.0.0",
@@ -43,7 +45,6 @@
"nelmio/security-bundle": "^3.0",
"nyholm/psr7": "^1.1",
"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",
@@ -68,7 +69,7 @@
"symfony/http-client": "7.4.*",
"symfony/http-kernel": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/monolog-bundle": "^3.1",
"symfony/monolog-bundle": "^4.0",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
@@ -79,15 +80,16 @@
"symfony/string": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/type-info": "7.4.0",
"symfony/type-info": "7.4.*",
"symfony/ux-translator": "^2.32.0",
"symfony/ux-turbo": "^2.0",
"symfony/validator": "7.4.*",
"symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^v2.0.1",
"symfony/yaml": "7.4.*",
"symplify/easy-coding-standard": "^12.5.20",
"symplify/easy-coding-standard": "^13.0",
"tecnickcom/tc-lib-barcode": "^2.1.4",
"tiendanube/gtinvalidation": "^1.0",
"twig/cssinliner-extra": "^3.0",
"twig/extra-bundle": "^3.8",
"twig/html-extra": "^3.8",
@@ -126,7 +128,7 @@
},
"suggest": {
"ext-bcmath": "Used to improve price calculation performance",
"ext-gmp": "Used to improve price calculation performanice"
"ext-gmp": "Used to improve price calculation performance"
},
"config": {
"preferred-install": {

1723
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,3 +23,7 @@ framework:
info_provider.cache:
adapter: cache.app
cache.settings:
adapter: cache.app
tags: true

View File

@@ -20,12 +20,14 @@
declare(strict_types=1);
use Symfony\Config\DoctrineConfig;
/**
* 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) {
return static function(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

@@ -1,3 +1,4 @@
# yaml-language-server: $schema=../../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
@@ -8,6 +9,7 @@ framework:
# Must be set to true, to enable the change of HTTP method via _method parameter, otherwise our delete routines does not work anymore
# TODO: Rework delete routines to work without _method parameter as it is not recommended anymore (see https://github.com/symfony/symfony/issues/45278)
http_method_override: true
allowed_http_method_override: ['DELETE']
# Allow users to configure trusted hosts via .env variables
# see https://symfony.com/doc/current/reference/configuration/framework.html#trusted-hosts

View File

@@ -3,6 +3,7 @@ jbtronics_settings:
cache:
default_cacheable: true
service: 'cache.settings'
orm_storage:
default_entity_class: App\Entity\SettingsEntry

View File

@@ -68,6 +68,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
move:
label: "perm.parts_stock.move"
apiTokenRole: ROLE_API_EDIT
stocktake:
label: "perm.parts_stock.stocktake"
apiTokenRole: ROLE_API_EDIT
storelocations: &PART_CONTAINING
@@ -297,6 +300,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
show_updates:
label: "perm.system.show_available_updates"
apiTokenRole: ROLE_API_ADMIN
manage_updates:
label: "perm.system.manage_updates"
alsoSet: ['show_updates', 'server_infos']
apiTokenRole: ROLE_API_ADMIN
attachments:

File diff suppressed because it is too large Load Diff

View File

@@ -5,3 +5,5 @@ files:
translation: /translations/validators.%two_letters_code%.xlf
- source: /translations/security.en.xlf
translation: /translations/security.%two_letters_code%.xlf
- source: /translations/frontend.en.xlf
translation: /translations/frontend.%two_letters_code%.xlf

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -50,6 +50,14 @@ docker exec --user=www-data partdb php bin/console cache:clear
* `php bin/console partdb:currencies:update-exchange-rates`: Update the exchange rates of all currencies from the
internet
## Update Manager commands
{: .note }
> The Update Manager is an experimental feature. See the [Update Manager documentation](update_manager.md) for details.
* `php bin/console partdb:update`: Check for and perform updates to Part-DB. Use `--check` to only check for updates without installing.
* `php bin/console partdb:maintenance-mode`: Enable, disable, or check the status of maintenance mode. Use `--enable`, `--disable`, or `--status`.
## Installation/Maintenance commands
* `php bin/console partdb:backup`: Backup the database and the attachments

View File

@@ -96,6 +96,21 @@ The following providers are currently available and shipped with Part-DB:
(All trademarks are property of their respective owners. Part-DB is not affiliated with any of the companies.)
### Generic Web URL Provider
The Generic Web URL Provider can extract part information from any webpage that contains structured data in the form of
[Schema.org](https://schema.org/) format. Many e-commerce websites use this format to provide detailed product information
for search engines and other services. Therefore it allows Part-DB to retrieve rudimentary part information (like name, image and price)
from a wide range of websites without the need for a dedicated API integration.
To use the Generic Web URL Provider, simply enable it in the information provider settings. No additional configuration
is required. Afterwards you can enter any product URL in the search field, and Part-DB will attempt to extract the relevant part information
from the webpage.
Please note that if this provider is enabled, Part-DB will make HTTP requests to external websites to fetch product data, which
may have privacy and security implications.
Following env configuration options are available:
* `PROVIDER_GENERIC_WEB_ENABLED`: Set this to `1` to enable the Generic Web URL Provider (optional, default: `0`)
### Octopart
The Octopart provider uses the [Octopart / Nexar API](https://nexar.com/api) to search for parts and get information.
@@ -278,6 +293,16 @@ The following env configuration options are available:
* `PROVIDER_BUERKLIN_CURRENCY`: The currency you want to get prices in if available (optional, 3 letter ISO-code, default: `EUR`).
* `PROVIDER_BUERKLIN_LANGUAGE`: The language you want to get the descriptions in. Possible values: `de` = German, `en` = English. (optional, default: `en`)
### Conrad
The conrad provider the [Conrad API](https://developer.conrad.com/) to search for parts and retried their information.
To use it you have to request access to the API, however it seems currently your mail address needs to be allowlisted before you can register for an account.
The conrad webpages uses the API key in the requests, so you might be able to extract a working API key by listening to browser requests.
That method is not officially supported nor encouraged by Part-DB, and might break at any moment.
The following env configuration options are available:
* `PROVIDER_CONRAD_API_KEY`: The API key you got from Conrad (mandatory)
### Custom provider
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long

View File

@@ -91,18 +91,20 @@ in [official documentation](https://twig.symfony.com/doc/3.x/).
Twig allows you for much more complex and dynamic label generation. You can use loops, conditions, and functions to create
the label content and you can access almost all data Part-DB offers. The label templates are evaluated in a special sandboxed environment,
where only certain operations are allowed. Only read access to entities is allowed. However as it circumvents Part-DB normal permission system,
where only certain operations are allowed. Only read access to entities is allowed. However, as it circumvents Part-DB normal permission system,
the twig mode is only available to users with the "Twig mode" permission.
It is useful to use the HTML embed feature of the editor, to have a block where you can write the twig code without worrying about the WYSIWYG editor messing with your code.
The following variables are in injected into Twig and can be accessed using `{% raw %}{{ variable }}{% endraw %}` (
or `{% raw %}{{ variable.property }}{% endraw %}`):
| Variable name | Description |
|--------------------------------------------|--------------------------------------------------------------------------------------|
| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. |
| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. |
| `{% raw %}{{ user }}{% endraw %}` | The current logged in user. Null if you are not logged in |
| `{% raw %}{{ install_title }}{% endraw %}` | The name of the current Part-DB instance (similar to [[INSTALL_NAME]] placeholder). |
| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated |
| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated ) |
| `{% raw %}{{ last_page }}{% endraw %}` | The page number of the last element. Equals the number of all pages / element labels |
| `{% raw %}{{ paper_width }}{% endraw %}` | The width of the label paper in mm |
| `{% raw %}{{ paper_height }}{% endraw %}` | The height of the label paper in mm |
@@ -236,12 +238,18 @@ certain data:
#### Functions
| Function name | Description |
|----------------------------------------------|-----------------------------------------------------------------------------------------------|
| `placeholder(placeholder, element)` | Get the value of a placeholder of an element |
| `entity_type(element)` | Get the type of an entity as string |
| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) |
| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. |
| Function name | Description |
|------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `placeholder(placeholder, element)` | Get the value of a placeholder of an element |
| `entity_type(element)` | Get the type of an entity as string |
| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) |
| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. |
| `associated_parts(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. Only the directly associated parts are returned |
| `associated_parts_r(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. including all sub-entities recursively (e.g. sub-locations) |
| `associated_parts_count(element)` | Get the count of associated parts of an element like a storagelocation, footprint, excluding sub-entities |
| `associated_parts_count_r(element)` | Get the count of associated parts of an element like a storagelocation, footprint, including all sub-entities recursively (e.g. sub-locations) |
| `type_label(element)` | Get the name of the type of an element (e.g. "Part", "Storage location", etc.) |
| `type_label_p(element)` | Get the name of the type of an element in plural form (e.g. "Parts", "Storage locations", etc.) |
### Filters
@@ -285,5 +293,5 @@ If you want to use a different (more beautiful) font, you can use the [custom fo
feature.
There is the [Noto](https://www.google.com/get/noto/) font family from Google, which supports a lot of languages and is
available in different styles (regular, bold, italic, bold-italic).
For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese,
and Korean characters.
For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese,
and Korean characters.

View File

@@ -0,0 +1,170 @@
---
title: Update Manager
layout: default
parent: Usage
---
# Update Manager (Experimental)
{: .warning }
> The Update Manager is currently an **experimental feature**. It is disabled by default while user experience data is being gathered. Use with caution and always ensure you have proper backups before updating.
Part-DB includes an Update Manager that can automatically update Git-based installations to newer versions. The Update Manager provides both a web interface and CLI commands for managing updates, backups, and maintenance mode.
## Supported Installation Types
The Update Manager currently supports automatic updates only for **Git clone** installations. Other installation types show manual update instructions:
| Installation Type | Auto-Update | Instructions |
|-------------------|-------------|--------------|
| Git Clone | Yes | Automatic via CLI or Web UI |
| Docker | No | Pull new image: `docker-compose pull && docker-compose up -d` |
| ZIP Release | No | Download and extract new release manually |
## Enabling the Update Manager
By default, web-based updates and backup restore are **disabled** for security reasons. To enable them, add these settings to your `.env.local` file:
```bash
# Enable web-based updates (default: disabled)
DISABLE_WEB_UPDATES=0
# Enable backup restore via web interface (default: disabled)
DISABLE_BACKUP_RESTORE=0
```
{: .note }
> Even with web updates disabled, you can still use the CLI commands to perform updates.
## CLI Commands
### Update Command
Check for updates or perform an update:
```bash
# Check for available updates
php bin/console partdb:update --check
# Update to the latest version
php bin/console partdb:update
# Update to a specific version
php bin/console partdb:update v2.6.0
# Update without creating a backup first
php bin/console partdb:update --no-backup
# Force update without confirmation prompt
php bin/console partdb:update --force
```
### Maintenance Mode Command
Manually enable or disable maintenance mode:
```bash
# Enable maintenance mode with default message
php bin/console partdb:maintenance-mode --enable
# Enable with custom message
php bin/console partdb:maintenance-mode --enable "System maintenance until 6 PM"
php bin/console partdb:maintenance-mode --enable --message="Updating to v2.6.0"
# Disable maintenance mode
php bin/console partdb:maintenance-mode --disable
# Check current status
php bin/console partdb:maintenance-mode --status
```
## Web Interface
When web updates are enabled, the Update Manager is accessible at **System > Update Manager** (URL: `/system/update-manager`).
The web interface shows:
- Current version and installation type
- Available updates with release notes
- Precondition validation (Git, Composer, Yarn, permissions)
- Update history and logs
- Backup management
### Required Permissions
Users need the following permissions to access the Update Manager:
| Permission | Description |
|------------|-------------|
| `@system.show_updates` | View update status and available versions |
| `@system.manage_updates` | Perform updates and restore backups |
## Update Process
When an update is performed, the following steps are executed:
1. **Lock** - Acquire exclusive lock to prevent concurrent updates
2. **Maintenance Mode** - Enable maintenance mode to block user access
3. **Rollback Tag** - Create a Git tag for potential rollback
4. **Backup** - Create a full backup (optional but recommended)
5. **Git Fetch** - Fetch latest changes from origin
6. **Git Checkout** - Checkout the target version
7. **Composer Install** - Install/update PHP dependencies
8. **Yarn Install** - Install frontend dependencies
9. **Yarn Build** - Compile frontend assets
10. **Database Migrations** - Run any new migrations
11. **Cache Clear** - Clear the application cache
12. **Cache Warmup** - Rebuild the cache
13. **Maintenance Off** - Disable maintenance mode
14. **Unlock** - Release the update lock
If any step fails, the system automatically attempts to rollback to the previous version.
## Backup Management
The Update Manager automatically creates backups before updates. These backups are stored in `var/backups/` and include:
- Database dump (SQL file or SQLite database)
- Configuration files (`.env.local`, `parameters.yaml`, `banner.md`)
- Attachment files (`uploads/`, `public/media/`)
### Restoring from Backup
{: .warning }
> Backup restore is a destructive operation that will overwrite your current database. Only use this if you need to recover from a failed update.
If web restore is enabled (`DISABLE_BACKUP_RESTORE=0`), you can restore backups from the web interface. The restore process:
1. Enables maintenance mode
2. Extracts the backup
3. Restores the database
4. Optionally restores config and attachments
5. Clears and warms up the cache
6. Disables maintenance mode
## Troubleshooting
### Precondition Errors
Before updating, the system validates:
- **Git available**: Git must be installed and in PATH
- **No local changes**: Uncommitted changes must be committed or stashed
- **Composer available**: Composer must be installed and in PATH
- **Yarn available**: Yarn must be installed and in PATH
- **Write permissions**: `var/`, `vendor/`, and `public/` must be writable
- **Not already locked**: No other update can be in progress
### Stale Lock
If an update was interrupted and the lock file remains, it will automatically be removed after 1 hour. You can also manually delete `var/update.lock`.
### Viewing Update Logs
Update logs are stored in `var/log/updates/` and can be viewed from the web interface or directly on the server.
## Security Considerations
- **Disable web updates in production** unless you specifically need them
- The Update Manager requires shell access to run Git, Composer, and Yarn
- Backup files may contain sensitive data (database, config) - secure the `var/backups/` directory
- Consider running updates during maintenance windows with low user activity

View File

@@ -1,91 +0,0 @@
# 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,129 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20260208131116 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add GTIN fields, allowed targets for attachment types and last stocktake date for part lots and add include_vat field for price details.';
}
public function mySQLUp(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE attachment_types ADD allowed_targets LONGTEXT DEFAULT NULL');
$this->addSql('ALTER TABLE part_lots ADD last_stocktake_at DATETIME DEFAULT NULL');
$this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL');
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
$this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat TINYINT DEFAULT NULL');
}
public function mySQLDown(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE `attachment_types` DROP allowed_targets');
$this->addSql('DROP INDEX parts_idx_gtin ON `parts`');
$this->addSql('ALTER TABLE `parts` DROP gtin');
$this->addSql('ALTER TABLE part_lots DROP last_stocktake_at');
$this->addSql('ALTER TABLE `orderdetails` DROP prices_includes_vat');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('ALTER TABLE attachment_types ADD COLUMN allowed_targets CLOB DEFAULT NULL');
$this->addSql('ALTER TABLE part_lots ADD COLUMN last_stocktake_at DATETIME DEFAULT NULL');
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM parts');
$this->addSql('DROP TABLE parts');
$this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, ipn VARCHAR(100) DEFAULT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, gtin VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES part_custom_states (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO parts (id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint) SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM __temp__parts');
$this->addSql('DROP TABLE __temp__parts');
$this->addSql('CREATE INDEX parts_idx_name ON parts (name)');
$this->addSql('CREATE INDEX parts_idx_ipn ON parts (ipn)');
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON parts (datetime_added, name, last_modified, id, needs_review)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn)');
$this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment)');
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint)');
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category)');
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit)');
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer)');
$this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state)');
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
$this->addSql('ALTER TABLE orderdetails ADD COLUMN prices_includes_vat BOOLEAN DEFAULT NULL');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('CREATE TEMPORARY TABLE __temp__attachment_types AS SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM "attachment_types"');
$this->addSql('DROP TABLE "attachment_types"');
$this->addSql('CREATE TABLE "attachment_types" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, alternative_names CLOB DEFAULT NULL, filetype_filter CLOB NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, CONSTRAINT FK_EFAED719727ACA70 FOREIGN KEY (parent_id) REFERENCES "attachment_types" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EFAED719EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "attachment_types" (id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment) SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM __temp__attachment_types');
$this->addSql('DROP TABLE __temp__attachment_types');
$this->addSql('CREATE INDEX IDX_EFAED719727ACA70 ON "attachment_types" (parent_id)');
$this->addSql('CREATE INDEX IDX_EFAED719EA7100A1 ON "attachment_types" (id_preview_attachment)');
$this->addSql('CREATE INDEX attachment_types_idx_name ON "attachment_types" (name)');
$this->addSql('CREATE INDEX attachment_types_idx_parent_name ON "attachment_types" (parent_id, name)');
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner FROM part_lots');
$this->addSql('DROP TABLE part_lots');
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO part_lots (id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner) SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner FROM __temp__part_lots');
$this->addSql('DROP TABLE __temp__part_lots');
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM "parts"');
$this->addSql('DROP TABLE "parts"');
$this->addSql('CREATE TABLE "parts" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, ipn VARCHAR(100) DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES "orderdetails" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "parts" (id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id) SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM __temp__parts');
$this->addSql('DROP TABLE __temp__parts');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn)');
$this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON "parts" (id_preview_attachment)');
$this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON "parts" (id_part_custom_state)');
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON "parts" (id_category)');
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON "parts" (id_footprint)');
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON "parts" (id_part_unit)');
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON "parts" (id_manufacturer)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON "parts" (order_orderdetails_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON "parts" (built_project_id)');
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review)');
$this->addSql('CREATE INDEX parts_idx_name ON "parts" (name)');
$this->addSql('CREATE INDEX parts_idx_ipn ON "parts" (ipn)');
$this->addSql('CREATE TEMPORARY TABLE __temp__orderdetails AS SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM "orderdetails"');
$this->addSql('DROP TABLE "orderdetails"');
$this->addSql('CREATE TABLE "orderdetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, supplierpartnr VARCHAR(255) NOT NULL, obsolete BOOLEAN NOT NULL, supplier_product_url CLOB NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, part_id INTEGER NOT NULL, id_supplier INTEGER DEFAULT NULL, CONSTRAINT FK_489AFCDC4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_489AFCDCCBF180EB FOREIGN KEY (id_supplier) REFERENCES "suppliers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "orderdetails" (id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier) SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM __temp__orderdetails');
$this->addSql('DROP TABLE __temp__orderdetails');
$this->addSql('CREATE INDEX IDX_489AFCDC4CE34BEC ON "orderdetails" (part_id)');
$this->addSql('CREATE INDEX IDX_489AFCDCCBF180EB ON "orderdetails" (id_supplier)');
$this->addSql('CREATE INDEX orderdetails_supplier_part_nr ON "orderdetails" (supplierpartnr)');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE attachment_types ADD allowed_targets TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE part_lots ADD last_stocktake_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL');
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
$this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat BOOLEAN DEFAULT NULL');
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE "attachment_types" DROP allowed_targets');
$this->addSql('ALTER TABLE part_lots DROP last_stocktake_at');
$this->addSql('DROP INDEX parts_idx_gtin');
$this->addSql('ALTER TABLE "parts" DROP gtin');
$this->addSql('ALTER TABLE "orderdetails" DROP prices_includes_vat');
}
}

View File

@@ -46,6 +46,7 @@
"@zxcvbn-ts/language-en": "^3.0.1",
"@zxcvbn-ts/language-fr": "^3.0.1",
"@zxcvbn-ts/language-ja": "^3.0.1",
"attr-accept": "^2.2.5",
"barcode-detector": "^3.0.5",
"bootbox": "^6.0.0",
"bootswatch": "^5.1.3",

View File

@@ -6,6 +6,9 @@ parameters:
- src
# - tests
banned_code:
non_ignorable: false # Allow to ignore some banned code
excludePaths:
- src/DataTables/Adapter/*
- src/Configuration/*
@@ -61,3 +64,9 @@ parameters:
# Ignore error of unused WithPermPresetsTrait, as it is used in the migrations which are not analyzed by Phpstan
- '#Trait App\\Migration\\WithPermPresetsTrait is used zero times and is not analysed#'
-
message: '#Should not use function "shell_exec"#'
path: src/Services/System/UpdateExecutor.php
- message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#'
path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php

View File

@@ -18,7 +18,7 @@ use Rector\Symfony\Set\SymfonySetList;
use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector;
return RectorConfig::configure()
->withComposerBased(phpunit: true)
->withComposerBased(phpunit: true, symfony: true)
->withSymfonyContainerPhp(__DIR__ . '/tests/symfony-container.php')
->withSymfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml')
@@ -36,8 +36,6 @@ return RectorConfig::configure()
PHPUnitSetList::PHPUNIT_90,
PHPUnitSetList::PHPUNIT_110,
PHPUnitSetList::PHPUNIT_CODE_QUALITY,
])
->withRules([
@@ -59,6 +57,9 @@ return RectorConfig::configure()
PreferPHPUnitThisCallRector::class,
//Do not replace 'GET' with class constant,
LiteralGetToRequestClassConstantRector::class,
//Do not move help text of commands to the command class, as we want to keep the help text in the command definition for better readability
\Rector\Symfony\Symfony73\Rector\Class_\CommandHelpToAttributeRector::class
])
//Do not apply rules to Symfony own files
@@ -67,6 +68,7 @@ return RectorConfig::configure()
__DIR__ . '/src/Kernel.php',
__DIR__ . '/config/preload.php',
__DIR__ . '/config/bundles.php',
__DIR__ . '/config/reference.php'
])
;

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\OpenApi\Model\RequestBody;
use ApiPlatform\OpenApi\Model\Response;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\State\LabelGenerationProcessor;
use App\Validator\Constraints\Misc\ValidRange;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource for generating PDF labels for parts, part lots, or storage locations.
* This endpoint allows generating labels using saved label profiles.
*/
#[ApiResource(
uriTemplate: '/labels/generate',
description: 'Generate PDF labels for parts, part lots, or storage locations using label profiles.',
operations: [
new Post(
inputFormats: ['json' => ['application/json']],
outputFormats: [],
openapi: new Operation(
responses: [
"200" => new Response(description: "PDF file containing the generated labels"),
],
summary: 'Generate PDF labels',
description: 'Generate PDF labels for one or more elements using a label profile. Returns a PDF file.',
requestBody: new RequestBody(
description: 'Label generation request',
required: true,
),
),
)
],
processor: LabelGenerationProcessor::class,
)]
class LabelGenerationRequest
{
/**
* @var int The ID of the label profile to use for generation
*/
#[Assert\NotBlank(message: 'Profile ID is required')]
#[Assert\Positive(message: 'Profile ID must be a positive integer')]
public int $profileId = 0;
/**
* @var string Comma-separated list of element IDs or ranges (e.g., "1,2,5-10,15")
*/
#[Assert\NotBlank(message: 'Element IDs are required')]
#[ValidRange()]
#[ApiProperty(example: "1,2,5-10,15")]
public string $elementIds = '';
/**
* @var LabelSupportedElement|null Optional: Override the element type. If not provided, uses profile's default.
*/
public ?LabelSupportedElement $elementType = null;
}

View File

@@ -0,0 +1,141 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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\Command;
use App\Services\System\UpdateExecutor;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:maintenance-mode', 'Enable/disable maintenance mode and set a message')]
class MaintenanceModeCommand extends Command
{
public function __construct(
private readonly UpdateExecutor $updateExecutor
) {
parent::__construct();
}
protected function configure(): void
{
$this
->setDefinition([
new InputOption('enable', null, InputOption::VALUE_NONE, 'Enable maintenance mode'),
new InputOption('disable', null, InputOption::VALUE_NONE, 'Disable maintenance mode'),
new InputOption('status', null, InputOption::VALUE_NONE, 'Show current maintenance mode status'),
new InputOption('message', null, InputOption::VALUE_REQUIRED, 'Optional maintenance message (explicit option)'),
new InputArgument('message_arg', InputArgument::OPTIONAL, 'Optional maintenance message as a positional argument (preferred when writing message directly)')
]);
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$enable = (bool)$input->getOption('enable');
$disable = (bool)$input->getOption('disable');
$status = (bool)$input->getOption('status');
// Accept message either via --message option or as positional argument
$optionMessage = $input->getOption('message');
$argumentMessage = $input->getArgument('message_arg');
// Prefer explicit --message option, otherwise use positional argument if provided
$message = null;
if (is_string($optionMessage) && $optionMessage !== '') {
$message = $optionMessage;
} elseif (is_string($argumentMessage) && $argumentMessage !== '') {
$message = $argumentMessage;
}
// If no action provided, show help
if (!$enable && !$disable && !$status) {
$io->text('Maintenance mode command. See usage below:');
$this->printHelp($io);
return Command::SUCCESS;
}
if ($enable && $disable) {
$io->error('Conflicting options: specify either --enable or --disable, not both.');
return Command::FAILURE;
}
try {
if ($status) {
if ($this->updateExecutor->isMaintenanceMode()) {
$info = $this->updateExecutor->getMaintenanceInfo();
$reason = $info['reason'] ?? 'Unknown reason';
$enabledAt = $info['enabled_at'] ?? 'Unknown time';
$io->success(sprintf('Maintenance mode is ENABLED (since %s).', $enabledAt));
$io->text(sprintf('Reason: %s', $reason));
} else {
$io->success('Maintenance mode is DISABLED.');
}
// If only status requested, exit
if (!$enable && !$disable) {
return Command::SUCCESS;
}
}
if ($enable) {
// Use provided message or fallback to a default English message
$reason = is_string($message)
? $message
: 'The system is temporarily unavailable due to maintenance.';
$this->updateExecutor->enableMaintenanceMode($reason);
$io->success(sprintf('Maintenance mode enabled. Reason: %s', $reason));
}
if ($disable) {
$this->updateExecutor->disableMaintenanceMode();
$io->success('Maintenance mode disabled.');
}
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error(sprintf('Unexpected error: %s', $e->getMessage()));
return Command::FAILURE;
}
}
private function printHelp(SymfonyStyle $io): void
{
$io->writeln('');
$io->writeln('Usage:');
$io->writeln(' php bin/console partdb:maintenance_mode --enable [--message="Maintenance message"]');
$io->writeln(' php bin/console partdb:maintenance_mode --enable "Maintenance message"');
$io->writeln(' php bin/console partdb:maintenance_mode --disable');
$io->writeln(' php bin/console partdb:maintenance_mode --status');
$io->writeln('');
}
}

View File

@@ -0,0 +1,445 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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\Command;
use App\Services\System\UpdateChecker;
use App\Services\System\UpdateExecutor;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'partdb:update', description: 'Check for and install Part-DB updates', aliases: ['app:update'])]
class UpdateCommand extends Command
{
public function __construct(private readonly UpdateChecker $updateChecker,
private readonly UpdateExecutor $updateExecutor)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setHelp(<<<'HELP'
The <info>%command.name%</info> command checks for Part-DB updates and can install them.
<comment>Check for updates:</comment>
<info>php %command.full_name% --check</info>
<comment>List available versions:</comment>
<info>php %command.full_name% --list</info>
<comment>Update to the latest version:</comment>
<info>php %command.full_name%</info>
<comment>Update to a specific version:</comment>
<info>php %command.full_name% v2.6.0</info>
<comment>Update without creating a backup (faster but riskier):</comment>
<info>php %command.full_name% --no-backup</info>
<comment>Non-interactive update for scripts:</comment>
<info>php %command.full_name% --force</info>
<comment>View update logs:</comment>
<info>php %command.full_name% --logs</info>
HELP
)
->addArgument(
'version',
InputArgument::OPTIONAL,
'Target version to update to (e.g., v2.6.0). If not specified, updates to the latest stable version.'
)
->addOption(
'check',
'c',
InputOption::VALUE_NONE,
'Only check for updates without installing'
)
->addOption(
'list',
'l',
InputOption::VALUE_NONE,
'List all available versions'
)
->addOption(
'no-backup',
null,
InputOption::VALUE_NONE,
'Skip creating a backup before updating (not recommended)'
)
->addOption(
'force',
'f',
InputOption::VALUE_NONE,
'Skip confirmation prompts'
)
->addOption(
'include-prerelease',
null,
InputOption::VALUE_NONE,
'Include pre-release versions'
)
->addOption(
'logs',
null,
InputOption::VALUE_NONE,
'Show recent update logs'
)
->addOption(
'refresh',
'r',
InputOption::VALUE_NONE,
'Force refresh of cached version information'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Handle --logs option
if ($input->getOption('logs')) {
return $this->showLogs($io);
}
// Handle --refresh option
if ($input->getOption('refresh')) {
$io->text('Refreshing version information...');
$this->updateChecker->refreshVersionInfo();
$io->success('Version cache cleared.');
}
// Handle --list option
if ($input->getOption('list')) {
return $this->listVersions($io, $input->getOption('include-prerelease'));
}
// Get update status
$status = $this->updateChecker->getUpdateStatus();
// Display current status
$io->title('Part-DB Update Manager');
$this->displayStatus($io, $status);
// Handle --check option
if ($input->getOption('check')) {
return $this->checkOnly($io, $status);
}
// Validate we can update
$validationResult = $this->validateUpdate($io, $status);
if ($validationResult !== null) {
return $validationResult;
}
// Determine target version
$targetVersion = $input->getArgument('version');
$includePrerelease = $input->getOption('include-prerelease');
if (!$targetVersion) {
$latest = $this->updateChecker->getLatestVersion($includePrerelease);
if (!$latest) {
$io->error('Could not determine the latest version. Please specify a version manually.');
return Command::FAILURE;
}
$targetVersion = $latest['tag'];
}
// Validate target version
if (!$this->updateChecker->isNewerVersionThanCurrent($targetVersion)) {
$io->warning(sprintf(
'Version %s is not newer than the current version %s.',
$targetVersion,
$status['current_version']
));
if (!$input->getOption('force')) {
if (!$io->confirm('Do you want to proceed anyway?', false)) {
$io->info('Update cancelled.');
return Command::SUCCESS;
}
}
}
// Confirm update
if (!$input->getOption('force')) {
$io->section('Update Plan');
$io->listing([
sprintf('Target version: <info>%s</info>', $targetVersion),
$input->getOption('no-backup')
? '<fg=yellow>Backup will be SKIPPED</>'
: 'A full backup will be created before updating',
'Maintenance mode will be enabled during update',
'Database migrations will be run automatically',
'Cache will be cleared and rebuilt',
]);
$io->warning('The update process may take several minutes. Do not interrupt it.');
if (!$io->confirm('Do you want to proceed with the update?', false)) {
$io->info('Update cancelled.');
return Command::SUCCESS;
}
}
// Execute update
return $this->executeUpdate($io, $targetVersion, !$input->getOption('no-backup'));
}
private function displayStatus(SymfonyStyle $io, array $status): void
{
$io->definitionList(
['Current Version' => sprintf('<info>%s</info>', $status['current_version'])],
['Latest Version' => $status['latest_version']
? sprintf('<info>%s</info>', $status['latest_version'])
: '<fg=yellow>Unknown</>'],
['Installation Type' => $status['installation']['type_name']],
['Git Branch' => $status['git']['branch'] ?? '<fg=gray>N/A</>'],
['Git Commit' => $status['git']['commit'] ?? '<fg=gray>N/A</>'],
['Local Changes' => $status['git']['has_local_changes']
? '<fg=yellow>Yes (update blocked)</>'
: '<fg=green>No</>'],
['Commits Behind' => $status['git']['commits_behind'] > 0
? sprintf('<fg=yellow>%d</>', $status['git']['commits_behind'])
: '<fg=green>0</>'],
['Update Available' => $status['update_available']
? '<fg=green>Yes</>'
: 'No'],
['Can Auto-Update' => $status['can_auto_update']
? '<fg=green>Yes</>'
: '<fg=yellow>No</>'],
);
if (!empty($status['update_blockers'])) {
$io->warning('Update blockers: ' . implode(', ', $status['update_blockers']));
}
}
private function checkOnly(SymfonyStyle $io, array $status): int
{
if (!$status['check_enabled']) {
$io->warning('Update checking is disabled in privacy settings.');
return Command::SUCCESS;
}
if ($status['update_available']) {
$io->success(sprintf(
'A new version is available: %s (current: %s)',
$status['latest_version'],
$status['current_version']
));
if ($status['release_url']) {
$io->text(sprintf('Release notes: <href=%s>%s</>', $status['release_url'], $status['release_url']));
}
if ($status['can_auto_update']) {
$io->text('');
$io->text('Run <info>php bin/console partdb:update</info> to update.');
} else {
$io->text('');
$io->text($status['installation']['update_instructions']);
}
return Command::SUCCESS;
}
$io->success('You are running the latest version.');
return Command::SUCCESS;
}
private function validateUpdate(SymfonyStyle $io, array $status): ?int
{
// Check if update checking is enabled
if (!$status['check_enabled']) {
$io->error('Update checking is disabled in privacy settings. Enable it to use automatic updates.');
return Command::FAILURE;
}
// Check installation type
if (!$status['can_auto_update']) {
$io->error('Automatic updates are not supported for this installation type.');
$io->text($status['installation']['update_instructions']);
return Command::FAILURE;
}
// Validate preconditions
$validation = $this->updateExecutor->validateUpdatePreconditions();
if (!$validation['valid']) {
$io->error('Cannot proceed with update:');
$io->listing($validation['errors']);
return Command::FAILURE;
}
return null;
}
private function executeUpdate(SymfonyStyle $io, string $targetVersion, bool $createBackup): int
{
$io->section('Executing Update');
$io->text(sprintf('Updating to version: <info>%s</info>', $targetVersion));
$io->text('');
$progressCallback = function (array $step) use ($io): void {
$icon = $step['success'] ? '<fg=green>✓</>' : '<fg=red>✗</>';
$duration = $step['duration'] ? sprintf(' <fg=gray>(%.1fs)</>', $step['duration']) : '';
$io->text(sprintf(' %s <info>%s</info>: %s%s', $icon, $step['step'], $step['message'], $duration));
};
// Use executeUpdateWithProgress to update the progress file for web UI
$result = $this->updateExecutor->executeUpdateWithProgress($targetVersion, $createBackup, $progressCallback);
$io->text('');
if ($result['success']) {
$io->success(sprintf(
'Successfully updated to %s in %.1f seconds!',
$targetVersion,
$result['duration']
));
$io->text([
sprintf('Rollback tag: <info>%s</info>', $result['rollback_tag']),
sprintf('Log file: <info>%s</info>', $result['log_file']),
]);
$io->note('If you encounter any issues, you can rollback using: git checkout ' . $result['rollback_tag']);
return Command::SUCCESS;
}
$io->error('Update failed: ' . $result['error']);
if ($result['rollback_tag']) {
$io->warning(sprintf('System was rolled back to: %s', $result['rollback_tag']));
}
if ($result['log_file']) {
$io->text(sprintf('See log file for details: %s', $result['log_file']));
}
return Command::FAILURE;
}
private function listVersions(SymfonyStyle $io, bool $includePrerelease): int
{
$releases = $this->updateChecker->getAvailableReleases(15);
$currentVersion = $this->updateChecker->getCurrentVersionString();
if (empty($releases)) {
$io->warning('Could not fetch available versions. Check your internet connection.');
return Command::FAILURE;
}
$io->title('Available Part-DB Versions');
$table = new Table($io);
$table->setHeaders(['Tag', 'Version', 'Released', 'Status']);
foreach ($releases as $release) {
if (!$includePrerelease && $release['prerelease']) {
continue;
}
$version = $release['version'];
$status = [];
if (version_compare($version, $currentVersion, '=')) {
$status[] = '<fg=cyan>current</>';
} elseif (version_compare($version, $currentVersion, '>')) {
$status[] = '<fg=green>newer</>';
}
if ($release['prerelease']) {
$status[] = '<fg=yellow>pre-release</>';
}
$table->addRow([
$release['tag'],
$version,
(new \DateTime($release['published_at']))->format('Y-m-d'),
implode(' ', $status) ?: '-',
]);
}
$table->render();
$io->text('');
$io->text('Use <info>php bin/console partdb:update [tag]</info> to update to a specific version.');
return Command::SUCCESS;
}
private function showLogs(SymfonyStyle $io): int
{
$logs = $this->updateExecutor->getUpdateLogs();
if (empty($logs)) {
$io->info('No update logs found.');
return Command::SUCCESS;
}
$io->title('Recent Update Logs');
$table = new Table($io);
$table->setHeaders(['Date', 'File', 'Size']);
foreach (array_slice($logs, 0, 10) as $log) {
$table->addRow([
date('Y-m-d H:i:s', $log['date']),
$log['file'],
$this->formatBytes($log['size']),
]);
}
$table->render();
$io->text('');
$io->text('Log files are stored in: <info>var/log/updates/</info>');
return Command::SUCCESS;
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$unitIndex = 0;
while ($bytes >= 1024 && $unitIndex < count($units) - 1) {
$bytes /= 1024;
$unitIndex++;
}
return sprintf('%.1f %s', $bytes, $units[$unitIndex]);
}
}

View File

@@ -22,9 +22,9 @@ declare(strict_types=1);
*/
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use App\Services\Misc\GitVersionInfo;
use App\Services\System\GitVersionInfoProvider;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -33,7 +33,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:version|app:version', 'Shows the currently installed version of Part-DB.')]
class VersionCommand extends Command
{
public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfo $gitVersionInfo)
public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfoProvider $gitVersionInfo)
{
parent::__construct();
}
@@ -48,9 +48,9 @@ class VersionCommand extends Command
$message = 'Part-DB version: '. $this->versionManager->getVersion()->toString();
if ($this->gitVersionInfo->getGitBranchName() !== null) {
$message .= ' Git branch: '. $this->gitVersionInfo->getGitBranchName();
$message .= ', Git commit: '. $this->gitVersionInfo->getGitCommitHash();
if ($this->gitVersionInfo->getBranchName() !== null) {
$message .= ' Git branch: '. $this->gitVersionInfo->getBranchName();
$message .= ', Git commit: '. $this->gitVersionInfo->getCommitHash();
}
$io->success($message);

View File

@@ -24,9 +24,9 @@ namespace App\Controller;
use App\DataTables\LogDataTable;
use App\Entity\Parts\Part;
use App\Services\Misc\GitVersionInfo;
use App\Services\System\BannerHelper;
use App\Services\System\UpdateAvailableManager;
use App\Services\System\GitVersionInfoProvider;
use App\Services\System\UpdateAvailableFacade;
use Doctrine\ORM\EntityManagerInterface;
use Omines\DataTablesBundle\DataTableFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -43,8 +43,8 @@ class HomepageController extends AbstractController
#[Route(path: '/', name: 'homepage')]
public function homepage(Request $request, GitVersionInfo $versionInfo, EntityManagerInterface $entityManager,
UpdateAvailableManager $updateAvailableManager): Response
public function homepage(Request $request, GitVersionInfoProvider $versionInfo, EntityManagerInterface $entityManager,
UpdateAvailableFacade $updateAvailableManager): Response
{
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
@@ -77,8 +77,8 @@ class HomepageController extends AbstractController
return $this->render('homepage.html.twig', [
'banner' => $this->bannerHelper->getBanner(),
'git_branch' => $versionInfo->getGitBranchName(),
'git_commit' => $versionInfo->getGitCommitHash(),
'git_branch' => $versionInfo->getBranchName(),
'git_commit' => $versionInfo->getCommitHash(),
'show_first_steps' => $show_first_steps,
'datatable' => $table,
'new_version_available' => $updateAvailableManager->isUpdateAvailable(),

View File

@@ -30,6 +30,7 @@ use App\Form\InfoProviderSystem\PartSearchType;
use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry;
use App\Services\InfoProviderSystem\Providers\GenericWebProvider;
use App\Settings\AppSettings;
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
use Doctrine\ORM\EntityManagerInterface;
@@ -39,11 +40,15 @@ use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use function Symfony\Component\Translation\t;
#[Route('/tools/info_providers')]
@@ -178,6 +183,13 @@ class InfoProviderController extends AbstractController
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
} catch (OAuthReconnectRequiredException $e) {
$this->addFlash('error', t('info_providers.search.error.oauth_reconnect', ['%provider%' => $e->getProviderName()]));
} catch (TransportException $e) {
$this->addFlash('error', t('info_providers.search.error.transport_exception'));
$exceptionLogger->error('Transport error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
} catch (\RuntimeException $e) {
$this->addFlash('error', t('info_providers.search.error.general_exception', ['%type%' => (new \ReflectionClass($e))->getShortName()]));
//Log the exception
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
}
@@ -198,4 +210,58 @@ class InfoProviderController extends AbstractController
'update_target' => $update_target
]);
}
#[Route('/from_url', name: 'info_providers_from_url')]
public function fromURL(Request $request, GenericWebProvider $provider): Response
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
if (!$provider->isActive()) {
$this->addFlash('error', "Generic Web Provider is not active. Please enable it in the provider settings.");
return $this->redirectToRoute('info_providers_list');
}
$formBuilder = $this->createFormBuilder();
$formBuilder->add('url', UrlType::class, [
'label' => 'info_providers.from_url.url.label',
'required' => true,
]);
$formBuilder->add('submit', SubmitType::class, [
'label' => 'info_providers.search.submit',
]);
$form = $formBuilder->getForm();
$form->handleRequest($request);
$partDetail = null;
if ($form->isSubmitted() && $form->isValid()) {
//Try to retrieve the part detail from the given URL
$url = $form->get('url')->getData();
try {
$searchResult = $this->infoRetriever->searchByKeyword(
keyword: $url,
providers: [$provider]
);
if (count($searchResult) === 0) {
$this->addFlash('warning', t('info_providers.from_url.no_part_found'));
} else {
$searchResult = $searchResult[0];
//Redirect to the part creation page with the found part detail
return $this->redirectToRoute('info_providers_create_part', [
'providerKey' => $searchResult->provider_key,
'providerId' => $searchResult->provider_id,
]);
}
} catch (ExceptionInterface $e) {
$this->addFlash('error', t('info_providers.search.error.general_exception', ['%type%' => (new \ReflectionClass($e))->getShortName()]));
}
}
return $this->render('info_providers/from_url/from_url.html.twig', [
'form' => $form,
'partDetail' => $partDetail,
]);
}
}

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\DataTables\LogDataTable;
use App\Entity\Attachments\AttachmentUpload;
use App\Entity\Parts\Category;
@@ -54,12 +55,14 @@ use Exception;
use Omines\DataTablesBundle\DataTableFactory;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
@@ -135,6 +138,7 @@ final class PartController extends AbstractController
'description_params' => $this->partInfoSettings->extractParamsFromDescription ? $parameterExtractor->extractParameters($part->getDescription()) : [],
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
'withdraw_add_helper' => $withdrawAddHelper,
'highlightLotId' => $request->query->getInt('highlightLot', 0),
]
);
}
@@ -148,7 +152,7 @@ final class PartController extends AbstractController
$jobId = $request->query->get('jobId');
$bulkJob = null;
if ($jobId) {
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
// Verify user owns this job
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
$bulkJob = null;
@@ -169,7 +173,7 @@ final class PartController extends AbstractController
throw $this->createAccessDeniedException('Invalid CSRF token');
}
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) {
throw $this->createNotFoundException('Bulk import job not found');
}
@@ -335,7 +339,7 @@ final class PartController extends AbstractController
$jobId = $request->query->get('jobId');
$bulkJob = null;
if ($jobId) {
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
// Verify user owns this job
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
$bulkJob = null;
@@ -462,6 +466,54 @@ final class PartController extends AbstractController
);
}
#[Route(path: '/{id}/stocktake', name: 'part_stocktake', methods: ['POST'])]
#[IsCsrfTokenValid(new Expression("'part_stocktake-' ~ args['part'].getid()"), '_token')]
public function stocktakeHandler(Part $part, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper,
Request $request,
): Response
{
$partLot = $em->find(PartLot::class, $request->request->get('lot_id'));
//Check that the user is allowed to stocktake the partlot
$this->denyAccessUnlessGranted('stocktake', $partLot);
if (!$partLot instanceof PartLot) {
throw new \RuntimeException('Part lot not found!');
}
//Ensure that the partlot belongs to the part
if ($partLot->getPart() !== $part) {
throw new \RuntimeException("The origin partlot does not belong to the part!");
}
$actualAmount = (float) $request->request->get('actual_amount');
$comment = $request->request->get('comment');
$timestamp = null;
$timestamp_str = $request->request->getString('timestamp', '');
//Try to parse the timestamp
if ($timestamp_str !== '') {
$timestamp = new DateTime($timestamp_str);
}
$withdrawAddHelper->stocktake($partLot, $actualAmount, $comment, $timestamp);
//Ensure that the timestamp is not in the future
if ($timestamp !== null && $timestamp > new DateTime("+20min")) {
throw new \LogicException("The timestamp must not be in the future!");
}
//Save the changes to the DB
$em->flush();
$this->addFlash('success', 'part.withdraw.success');
//If a redirect was passed, then redirect there
if ($request->request->get('_redirect')) {
return $this->redirect($request->request->get('_redirect'));
}
//Otherwise just redirect to the part page
return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
}
#[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])]
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
{

View File

@@ -147,10 +147,7 @@ class SecurityController extends AbstractController
'label' => 'user.settings.pw_confirm.label',
],
'invalid_message' => 'password_must_match',
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
]);
$builder->add('submit', SubmitType::class, [

View File

@@ -27,8 +27,8 @@ use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\BuiltinAttachmentsFinder;
use App\Services\Doctrine\DBInfoHelper;
use App\Services\Doctrine\NatsortDebugHelper;
use App\Services\Misc\GitVersionInfo;
use App\Services\System\UpdateAvailableManager;
use App\Services\System\GitVersionInfoProvider;
use App\Services\System\UpdateAvailableFacade;
use App\Settings\AppSettings;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
@@ -47,16 +47,16 @@ class ToolsController extends AbstractController
}
#[Route(path: '/server_infos', name: 'tools_server_infos')]
public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager,
public function systemInfos(GitVersionInfoProvider $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableFacade $updateAvailableManager,
AppSettings $settings): Response
{
$this->denyAccessUnlessGranted('@system.server_infos');
return $this->render('tools/server_infos/server_infos.html.twig', [
//Part-DB section
'git_branch' => $versionInfo->getGitBranchName(),
'git_commit' => $versionInfo->getGitCommitHash(),
'git_branch' => $versionInfo->getBranchName(),
'git_commit' => $versionInfo->getCommitHash(),
'default_locale' => $settings->system->localization->locale,
'default_timezone' => $settings->system->localization->timezone,
'default_currency' => $settings->system->localization->baseCurrency,

View File

@@ -0,0 +1,371 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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\Services\System\BackupManager;
use App\Services\System\UpdateChecker;
use App\Services\System\UpdateExecutor;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
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\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Attribute\Route;
/**
* Controller for the Update Manager web interface.
*
* This provides a read-only view of update status and instructions.
* Actual updates should be performed via the CLI command for safety.
*/
#[Route('/system/update-manager')]
class UpdateManagerController extends AbstractController
{
public function __construct(
private readonly UpdateChecker $updateChecker,
private readonly UpdateExecutor $updateExecutor,
private readonly VersionManagerInterface $versionManager,
private readonly BackupManager $backupManager,
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
private readonly bool $webUpdatesDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
private readonly bool $backupRestoreDisabled = false,
) {
}
/**
* Check if web updates are disabled and throw exception if so.
*/
private function denyIfWebUpdatesDisabled(): void
{
if ($this->webUpdatesDisabled) {
throw new AccessDeniedHttpException('Web-based updates are disabled by server configuration. Please use the CLI command instead.');
}
}
/**
* Check if backup restore is disabled and throw exception if so.
*/
private function denyIfBackupRestoreDisabled(): void
{
if ($this->backupRestoreDisabled) {
throw new AccessDeniedHttpException('Backup restore is disabled by server configuration.');
}
}
/**
* Main update manager page.
*/
#[Route('', name: 'admin_update_manager', methods: ['GET'])]
public function index(): Response
{
$this->denyAccessUnlessGranted('@system.show_updates');
$status = $this->updateChecker->getUpdateStatus();
$availableUpdates = $this->updateChecker->getAvailableUpdates();
$validation = $this->updateExecutor->validateUpdatePreconditions();
return $this->render('admin/update_manager/index.html.twig', [
'status' => $status,
'available_updates' => $availableUpdates,
'all_releases' => $this->updateChecker->getAvailableReleases(10),
'validation' => $validation,
'is_locked' => $this->updateExecutor->isLocked(),
'lock_info' => $this->updateExecutor->getLockInfo(),
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(),
'update_logs' => $this->updateExecutor->getUpdateLogs(),
'backups' => $this->backupManager->getBackups(),
'web_updates_disabled' => $this->webUpdatesDisabled,
'backup_restore_disabled' => $this->backupRestoreDisabled,
]);
}
/**
* AJAX endpoint to check update status.
*/
#[Route('/status', name: 'admin_update_manager_status', methods: ['GET'])]
public function status(): JsonResponse
{
$this->denyAccessUnlessGranted('@system.show_updates');
return $this->json([
'status' => $this->updateChecker->getUpdateStatus(),
'is_locked' => $this->updateExecutor->isLocked(),
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
'lock_info' => $this->updateExecutor->getLockInfo(),
]);
}
/**
* AJAX endpoint to refresh version information.
*/
#[Route('/refresh', name: 'admin_update_manager_refresh', methods: ['POST'])]
public function refresh(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('@system.show_updates');
// Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_refresh', $request->request->get('_token'))) {
return $this->json(['error' => 'Invalid CSRF token'], Response::HTTP_FORBIDDEN);
}
$this->updateChecker->refreshVersionInfo();
return $this->json([
'success' => true,
'status' => $this->updateChecker->getUpdateStatus(),
]);
}
/**
* View release notes for a specific version.
*/
#[Route('/release/{tag}', name: 'admin_update_manager_release', methods: ['GET'])]
public function releaseNotes(string $tag): Response
{
$this->denyAccessUnlessGranted('@system.show_updates');
$releases = $this->updateChecker->getAvailableReleases(20);
$release = null;
foreach ($releases as $r) {
if ($r['tag'] === $tag) {
$release = $r;
break;
}
}
if (!$release) {
throw $this->createNotFoundException('Release not found');
}
return $this->render('admin/update_manager/release_notes.html.twig', [
'release' => $release,
'current_version' => $this->updateChecker->getCurrentVersionString(),
]);
}
/**
* View an update log file.
*/
#[Route('/log/{filename}', name: 'admin_update_manager_log', methods: ['GET'])]
public function viewLog(string $filename): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
// Security: Only allow viewing files from the update logs directory
$logs = $this->updateExecutor->getUpdateLogs();
$logPath = null;
foreach ($logs as $log) {
if ($log['file'] === $filename) {
$logPath = $log['path'];
break;
}
}
if (!$logPath || !file_exists($logPath)) {
throw $this->createNotFoundException('Log file not found');
}
$content = file_get_contents($logPath);
return $this->render('admin/update_manager/log_viewer.html.twig', [
'filename' => $filename,
'content' => $content,
]);
}
/**
* Start an update process.
*/
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
public function startUpdate(Request $request): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfWebUpdatesDisabled();
// Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_start', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token');
return $this->redirectToRoute('admin_update_manager');
}
// Check if update is already running
if ($this->updateExecutor->isLocked() || $this->updateExecutor->isUpdateRunning()) {
$this->addFlash('error', 'An update is already in progress.');
return $this->redirectToRoute('admin_update_manager');
}
$targetVersion = $request->request->get('version');
$createBackup = $request->request->getBoolean('backup', true);
if (!$targetVersion) {
// Get latest version if not specified
$latest = $this->updateChecker->getLatestVersion();
if (!$latest) {
$this->addFlash('error', 'Could not determine target version.');
return $this->redirectToRoute('admin_update_manager');
}
$targetVersion = $latest['tag'];
}
// Validate preconditions
$validation = $this->updateExecutor->validateUpdatePreconditions();
if (!$validation['valid']) {
$this->addFlash('error', implode(' ', $validation['errors']));
return $this->redirectToRoute('admin_update_manager');
}
// Start the background update
$pid = $this->updateExecutor->startBackgroundUpdate($targetVersion, $createBackup);
if (!$pid) {
$this->addFlash('error', 'Failed to start update process.');
return $this->redirectToRoute('admin_update_manager');
}
// Redirect to progress page
return $this->redirectToRoute('admin_update_manager_progress');
}
/**
* Update progress page.
*/
#[Route('/progress', name: 'admin_update_manager_progress', methods: ['GET'])]
public function progress(): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$progress = $this->updateExecutor->getProgress();
$currentVersion = $this->versionManager->getVersion()->toString();
// Determine if this is a downgrade
$isDowngrade = false;
if ($progress && isset($progress['target_version'])) {
$targetVersion = ltrim($progress['target_version'], 'v');
$isDowngrade = version_compare($targetVersion, $currentVersion, '<');
}
return $this->render('admin/update_manager/progress.html.twig', [
'progress' => $progress,
'is_locked' => $this->updateExecutor->isLocked(),
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
'is_downgrade' => $isDowngrade,
'current_version' => $currentVersion,
]);
}
/**
* AJAX endpoint to get update progress.
*/
#[Route('/progress/status', name: 'admin_update_manager_progress_status', methods: ['GET'])]
public function progressStatus(): JsonResponse
{
$this->denyAccessUnlessGranted('@system.show_updates');
$progress = $this->updateExecutor->getProgress();
return $this->json([
'progress' => $progress,
'is_locked' => $this->updateExecutor->isLocked(),
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
]);
}
/**
* Get backup details for restore confirmation.
*/
#[Route('/backup/{filename}', name: 'admin_update_manager_backup_details', methods: ['GET'])]
public function backupDetails(string $filename): JsonResponse
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$details = $this->backupManager->getBackupDetails($filename);
if (!$details) {
return $this->json(['error' => 'Backup not found'], 404);
}
return $this->json($details);
}
/**
* Restore from a backup.
*/
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
public function restore(Request $request): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfBackupRestoreDisabled();
// Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_restore', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
// Check if already locked
if ($this->updateExecutor->isLocked()) {
$this->addFlash('error', 'An update or restore is already in progress.');
return $this->redirectToRoute('admin_update_manager');
}
$filename = $request->request->get('filename');
$restoreDatabase = $request->request->getBoolean('restore_database', true);
$restoreConfig = $request->request->getBoolean('restore_config', false);
$restoreAttachments = $request->request->getBoolean('restore_attachments', false);
if (!$filename) {
$this->addFlash('error', 'No backup file specified.');
return $this->redirectToRoute('admin_update_manager');
}
// Verify the backup exists
$backupDetails = $this->backupManager->getBackupDetails($filename);
if (!$backupDetails) {
$this->addFlash('error', 'Backup file not found.');
return $this->redirectToRoute('admin_update_manager');
}
// Execute restore (this is a synchronous operation for now - could be made async later)
$result = $this->updateExecutor->restoreBackup(
$filename,
$restoreDatabase,
$restoreConfig,
$restoreAttachments
);
if ($result['success']) {
$this->addFlash('success', 'Backup restored successfully.');
} else {
$this->addFlash('error', 'Restore failed: ' . ($result['error'] ?? 'Unknown error'));
}
return $this->redirectToRoute('admin_update_manager');
}
}

View File

@@ -295,10 +295,7 @@ class UserSettingsController extends AbstractController
'autocomplete' => 'new-password',
],
],
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
])
->add('submit', SubmitType::class, [
'label' => 'save',

View File

@@ -66,6 +66,7 @@ class PartFilter implements FilterInterface
public readonly BooleanConstraint $favorite;
public readonly BooleanConstraint $needsReview;
public readonly NumberConstraint $mass;
public readonly TextConstraint $gtin;
public readonly DateTimeConstraint $lastModified;
public readonly DateTimeConstraint $addedDate;
public readonly EntityConstraint $category;
@@ -132,6 +133,7 @@ class PartFilter implements FilterInterface
$this->measurementUnit = new EntityConstraint($nodesListBuilder, MeasurementUnit::class, 'part.partUnit');
$this->partCustomState = new EntityConstraint($nodesListBuilder, PartCustomState::class, 'part.partCustomState');
$this->mass = new NumberConstraint('part.mass');
$this->gtin = new TextConstraint('part.gtin');
$this->dbId = new IntConstraint('part.id');
$this->ipn = new TextConstraint('part.ipn');
$this->addedDate = new DateTimeConstraint('part.addedDate');

View File

@@ -160,7 +160,7 @@ class PartSearchFilter implements FilterInterface
if ($search_dbId) {
$expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact');
$queryBuilder->setParameter('id_exact', (int) $this->keyword,
\Doctrine\DBAL\ParameterType::INTEGER);
ParameterType::INTEGER);
}
//Guard condition

View File

@@ -208,6 +208,7 @@ class LogDataTable implements DataTableTypeInterface
$dataTable->add('extra', LogEntryExtraColumn::class, [
'label' => 'log.extra',
'orderable' => false, //Sorting the JSON column makes no sense: MySQL/Sqlite does it via the string representation, PostgreSQL errors out
]);
$dataTable->add('timeTravel', IconLinkColumn::class, [

View File

@@ -47,6 +47,7 @@ use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Column\TextColumn;
@@ -218,6 +219,10 @@ final class PartsDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.mass'),
'unit' => 'g'
])
->add('gtin', TextColumn::class, [
'label' => $this->translator->trans('part.table.gtin'),
'orderField' => 'NATSORT(part.gtin)'
])
->add('tags', TagsColumn::class, [
'label' => $this->translator->trans('part.table.tags'),
])
@@ -329,6 +334,7 @@ final class PartsDataTable implements DataTableTypeInterface
->addSelect('orderdetails')
->addSelect('attachments')
->addSelect('storelocations')
->addSelect('projectBomEntries')
->from(Part::class, 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.master_picture_attachment', 'master_picture_attachment')
@@ -343,6 +349,7 @@ final class PartsDataTable implements DataTableTypeInterface
->leftJoin('part.partUnit', 'partUnit')
->leftJoin('part.partCustomState', 'partCustomState')
->leftJoin('part.parameters', 'parameters')
->leftJoin('part.project_bom_entries', 'projectBomEntries')
->where('part.id IN (:ids)')
->setParameter('ids', $ids)
@@ -360,7 +367,12 @@ final class PartsDataTable implements DataTableTypeInterface
->addGroupBy('attachments')
->addGroupBy('partUnit')
->addGroupBy('partCustomState')
->addGroupBy('parameters');
->addGroupBy('parameters')
->addGroupBy('projectBomEntries')
->setHint(Query::HINT_READ_ONLY, true)
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false)
;
//Get the results in the same order as the IDs were passed
FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids');

View File

@@ -97,7 +97,7 @@ use function in_array;
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
abstract class Attachment extends AbstractNamedDBElement
{
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
final public const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
'AttachmentType' => AttachmentTypeAttachment::class,
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
@@ -136,7 +136,7 @@ abstract class Attachment extends AbstractNamedDBElement
* @var string The class of the element that can be passed to this attachment. Must be overridden in subclasses.
* @phpstan-var class-string<T>
*/
protected const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
public const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
/**
* @var AttachmentUpload|null The options used for uploading a file to this attachment or modify it.

View File

@@ -134,6 +134,17 @@ class AttachmentType extends AbstractStructuralDBElement
#[ORM\OneToMany(mappedBy: 'attachment_type', targetEntity: Attachment::class)]
protected Collection $attachments_with_type;
/**
* @var string[]|null A list of allowed targets where this attachment type can be assigned to, as a list of portable names
*/
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
protected ?array $allowed_targets = null;
/**
* @var class-string<Attachment>[]|null
*/
protected ?array $allowed_targets_parsed_cache = null;
#[Groups(['attachment_type:read'])]
protected ?\DateTimeImmutable $addedDate = null;
#[Groups(['attachment_type:read'])]
@@ -184,4 +195,81 @@ class AttachmentType extends AbstractStructuralDBElement
return $this;
}
/**
* Returns a list of allowed targets as class names (e.g. PartAttachment::class), where this attachment type can be assigned to. If null, there are no restrictions.
* @return class-string<Attachment>[]|null
*/
public function getAllowedTargets(): ?array
{
//Use cached value if available
if ($this->allowed_targets_parsed_cache !== null) {
return $this->allowed_targets_parsed_cache;
}
if (empty($this->allowed_targets)) {
return null;
}
$tmp = [];
foreach ($this->allowed_targets as $target) {
if (isset(Attachment::ORM_DISCRIMINATOR_MAP[$target])) {
$tmp[] = Attachment::ORM_DISCRIMINATOR_MAP[$target];
}
//Otherwise ignore the entry, as it is invalid
}
//Cache the parsed value
$this->allowed_targets_parsed_cache = $tmp;
return $tmp;
}
/**
* Sets the allowed targets for this attachment type. Allowed targets are specified as a list of class names (e.g. PartAttachment::class). If null is passed, there are no restrictions.
* @param class-string<Attachment>[]|null $allowed_targets
* @return $this
*/
public function setAllowedTargets(?array $allowed_targets): self
{
if ($allowed_targets === null) {
$this->allowed_targets = null;
} else {
$tmp = [];
foreach ($allowed_targets as $target) {
$discriminator = array_search($target, Attachment::ORM_DISCRIMINATOR_MAP, true);
if ($discriminator !== false) {
$tmp[] = $discriminator;
} else {
throw new \InvalidArgumentException("Invalid allowed target: $target. Allowed targets must be a class name of an Attachment subclass.");
}
}
$this->allowed_targets = $tmp;
}
//Reset the cache
$this->allowed_targets_parsed_cache = null;
return $this;
}
/**
* Checks if this attachment type is allowed for the given attachment target.
* @param Attachment|string $attachment
* @return bool
*/
public function isAllowedForTarget(Attachment|string $attachment): bool
{
//If no restrictions are set, allow all targets
if ($this->getAllowedTargets() === null) {
return true;
}
//Iterate over all allowed targets and check if the attachment is an instance of any of them
foreach ($this->getAllowedTargets() as $allowed_target) {
if (is_a($attachment, $allowed_target, true)) {
return true;
}
}
return false;
}
}

View File

@@ -58,7 +58,7 @@ class EDACategoryInfo
/** @var bool|null If this is set to true, then this part will be excluded in the simulation */
#[Column(type: Types::BOOLEAN, nullable: true)]
#[Groups(['full', 'category:read', 'category:write', 'import'])]
private ?bool $exclude_from_sim = true;
private ?bool $exclude_from_sim = null;
/** @var string|null The KiCAD schematic symbol, which should be used (the path to the library) */
#[Column(type: Types::STRING, nullable: true)]

View File

@@ -41,6 +41,12 @@ declare(strict_types=1);
namespace App\Entity\LabelSystem;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\OpenApi\Model\Operation;
use Doctrine\Common\Collections\Criteria;
use App\Entity\Attachments\Attachment;
use App\Repository\LabelProfileRepository;
@@ -58,6 +64,22 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* @extends AttachmentContainingDBElement<LabelAttachment>
*/
#[ApiResource(
operations: [
new Get(
normalizationContext: ['groups' => ['label_profile:read', 'simple']],
security: "is_granted('read', object)",
openapi: new Operation(summary: 'Get a label profile by ID')
),
new GetCollection(
normalizationContext: ['groups' => ['label_profile:read', 'simple']],
security: "is_granted('@labels.create_labels')",
openapi: new Operation(summary: 'List all available label profiles')
),
],
paginationEnabled: false,
)]
#[ApiFilter(SearchFilter::class, properties: ['options.supported_element' => 'exact', 'show_in_dropdown' => 'exact'])]
#[UniqueEntity(['name', 'options.supported_element'])]
#[ORM\Entity(repositoryClass: LabelProfileRepository::class)]
#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
@@ -80,20 +102,21 @@ class LabelProfile extends AttachmentContainingDBElement
*/
#[Assert\Valid]
#[ORM\Embedded(class: 'LabelOptions')]
#[Groups(["extended", "full", "import"])]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected LabelOptions $options;
/**
* @var string The comment info for this element
*/
#[ORM\Column(type: Types::TEXT)]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected string $comment = '';
/**
* @var bool determines, if this label profile should be shown in the dropdown quick menu
*/
#[ORM\Column(type: Types::BOOLEAN)]
#[Groups(["extended", "full", "import"])]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected bool $show_in_dropdown = true;
public function __construct()

View File

@@ -28,6 +28,8 @@ enum PartStockChangeType: string
case WITHDRAW = "withdraw";
case MOVE = "move";
case STOCKTAKE = "stock_take";
/**
* Converts the type to a short representation usable in the extra field of the log entry.
* @return string
@@ -38,6 +40,7 @@ enum PartStockChangeType: string
self::ADD => 'a',
self::WITHDRAW => 'w',
self::MOVE => 'm',
self::STOCKTAKE => 's',
};
}
@@ -52,6 +55,7 @@ enum PartStockChangeType: string
'a' => self::ADD,
'w' => self::WITHDRAW,
'm' => self::MOVE,
's' => self::STOCKTAKE,
default => throw new \InvalidArgumentException("Invalid short type: $value"),
};
}

View File

@@ -122,6 +122,11 @@ class PartStockChangedLogEntry extends AbstractLogEntry
return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target, action_timestamp: $action_timestamp);
}
public static function stocktake(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self
{
return new self(PartStockChangeType::STOCKTAKE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp);
}
/**
* Returns the instock change type of this entry
* @return PartStockChangeType

View File

@@ -80,6 +80,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')]
#[ORM\Index(columns: ['name'], name: 'parts_idx_name')]
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
#[ORM\Index(columns: ['gtin'], name: 'parts_idx_gtin')]
#[ApiResource(
operations: [
new Get(normalizationContext: [

View File

@@ -171,6 +171,14 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
#[Length(max: 255)]
protected ?string $user_barcode = null;
/**
* @var \DateTimeImmutable|null The date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet.
*/
#[Groups(['extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
#[ORM\Column( type: Types::DATETIME_IMMUTABLE, nullable: true)]
#[Year2038BugWorkaround]
protected ?\DateTimeImmutable $last_stocktake_at = null;
public function __clone()
{
if ($this->id) {
@@ -391,6 +399,26 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
return $this;
}
/**
* Returns the date when the last stocktake was performed for this part lot. Returns null, if no stocktake was performed yet.
* @return \DateTimeImmutable|null
*/
public function getLastStocktakeAt(): ?\DateTimeImmutable
{
return $this->last_stocktake_at;
}
/**
* Sets the date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet.
* @param \DateTimeImmutable|null $last_stocktake_at
* @return $this
*/
public function setLastStocktakeAt(?\DateTimeImmutable $last_stocktake_at): self
{
$this->last_stocktake_at = $last_stocktake_at;
return $this;
}
#[Assert\Callback]

View File

@@ -24,6 +24,7 @@ namespace App\Entity\Parts\PartTraits;
use App\Entity\Parts\InfoProviderReference;
use App\Entity\Parts\PartCustomState;
use App\Validator\Constraints\ValidGTIN;
use Doctrine\DBAL\Types\Types;
use App\Entity\Parts\Part;
use Doctrine\ORM\Mapping as ORM;
@@ -84,6 +85,14 @@ trait AdvancedPropertyTrait
#[ORM\JoinColumn(name: 'id_part_custom_state')]
protected ?PartCustomState $partCustomState = null;
/**
* @var string|null The GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code
*/
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::STRING, nullable: true)]
#[ValidGTIN]
protected ?string $gtin = null;
/**
* Checks if this part is marked, for that it needs further review.
*/
@@ -211,4 +220,26 @@ trait AdvancedPropertyTrait
return $this;
}
/**
* Gets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code.
* Returns null if no GTIN is set.
*/
public function getGtin(): ?string
{
return $this->gtin;
}
/**
* Sets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code.
*
* @param string|null $gtin The new GTIN of the part
*
* @return $this
*/
public function setGtin(?string $gtin): self
{
$this->gtin = $gtin;
return $this;
}
}

View File

@@ -52,6 +52,7 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraints\Length;
@@ -147,6 +148,13 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
#[ORM\JoinColumn(name: 'id_supplier')]
protected ?Supplier $supplier = null;
/**
* @var bool|null Whether the prices includes VAT or not. Null means, that it is not specified, if the prices includes VAT or not.
*/
#[ORM\Column(type: Types::BOOLEAN, nullable: true)]
#[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
protected ?bool $prices_includes_vat = null;
public function __construct()
{
$this->pricedetails = new ArrayCollection();
@@ -388,6 +396,28 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
return $this;
}
/**
* Checks if the prices of this orderdetail include VAT. Null means, that it is not specified, if the prices includes
* VAT or not.
* @return bool|null
*/
public function getPricesIncludesVAT(): ?bool
{
return $this->prices_includes_vat;
}
/**
* Sets whether the prices of this orderdetail include VAT.
* @param bool|null $includesVat
* @return $this
*/
public function setPricesIncludesVAT(?bool $includesVat): self
{
$this->prices_includes_vat = $includesVat;
return $this;
}
public function getName(): string
{
return $this->getSupplierPartNr();

View File

@@ -121,6 +121,8 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
#[Groups(['pricedetail:read:standalone', 'pricedetail:write'])]
protected ?Orderdetail $orderdetail = null;
public function __construct()
{
$this->price = BigDecimal::zero()->toScale(self::PRICE_PRECISION);
@@ -264,6 +266,15 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
return $this->currency?->getIsoCode();
}
/**
* Returns whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not.
* @return bool|null
*/
public function getIncludesVat(): ?bool
{
return $this->orderdetail?->getPricesIncludesVAT();
}
/********************************************************************************
*
* Setters

View File

@@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable
/**
* The current schema version of the permission data
*/
public const CURRENT_SCHEMA_VERSION = 3;
public const CURRENT_SCHEMA_VERSION = 4;
/**
* Creates a new Permission Data Instance using the given data.

View File

@@ -0,0 +1,230 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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\EventSubscriber;
use App\Services\System\UpdateExecutor;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Twig\Environment;
/**
* Blocks all web requests when maintenance mode is enabled during updates.
*/
readonly class MaintenanceModeSubscriber implements EventSubscriberInterface
{
public function __construct(private UpdateExecutor $updateExecutor)
{
}
public static function getSubscribedEvents(): array
{
return [
// High priority to run before other listeners
KernelEvents::REQUEST => ['onKernelRequest', 512], //High priority to run before other listeners
];
}
public function onKernelRequest(RequestEvent $event): void
{
// Only handle main requests
if (!$event->isMainRequest()) {
return;
}
// Skip if not in maintenance mode
if (!$this->updateExecutor->isMaintenanceMode()) {
return;
}
//Allow to view the progress page
if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) {
return;
}
// Allow CLI requests
if (PHP_SAPI === 'cli') {
return;
}
// Get maintenance info
$maintenanceInfo = $this->updateExecutor->getMaintenanceInfo();
// Calculate how long the update has been running
$duration = null;
if ($maintenanceInfo && isset($maintenanceInfo['enabled_at'])) {
try {
$startedAt = new \DateTime($maintenanceInfo['enabled_at']);
$now = new \DateTime();
$duration = $now->getTimestamp() - $startedAt->getTimestamp();
} catch (\Exception) {
// Ignore date parsing errors
}
}
$content = $this->getSimpleMaintenanceHtml($maintenanceInfo, $duration);
$response = new Response($content, Response::HTTP_SERVICE_UNAVAILABLE);
$response->headers->set('Retry-After', '30');
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate');
$event->setResponse($response);
}
/**
* Generate a simple maintenance page HTML without Twig.
*/
private function getSimpleMaintenanceHtml(?array $maintenanceInfo, ?int $duration): string
{
$reason = htmlspecialchars($maintenanceInfo['reason'] ?? 'Update in progress');
$durationText = $duration !== null ? sprintf('%d seconds', $duration) : 'a moment';
$startDateStr = $maintenanceInfo['enabled_at'] ?? 'unknown time';
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="15">
<title>Part-DB - Maintenance</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
color: #ffffff;
}
.container {
text-align: center;
padding: 40px;
max-width: 600px;
}
.icon {
font-size: 80px;
margin-bottom: 30px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
display: inline-block;
animation: spin 2s linear infinite;
}
h1 {
font-size: 2.5rem;
margin-bottom: 20px;
color: #00d4ff;
}
p {
font-size: 1.2rem;
margin-bottom: 15px;
color: #b8c5d6;
}
.reason {
background: rgba(255, 255, 255, 0.1);
padding: 15px 25px;
border-radius: 10px;
margin: 20px 0;
font-size: 1rem;
}
.progress-bar {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
overflow: hidden;
margin: 30px 0;
}
.progress-bar-inner {
height: 100%;
background: linear-gradient(90deg, #00d4ff, #00ff88);
border-radius: 3px;
animation: progress 3s ease-in-out infinite;
}
@keyframes progress {
0% { width: 0%; margin-left: 0%; }
50% { width: 50%; margin-left: 25%; }
100% { width: 0%; margin-left: 100%; }
}
.info {
font-size: 0.9rem;
color: #8899aa;
margin-top: 30px;
}
.duration {
font-family: monospace;
background: rgba(0, 212, 255, 0.2);
padding: 3px 8px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">
<span class="spinner">⚙️</span>
</div>
<h1>Part-DB is under maintenance</h1>
<p>We're making things better. This should only take a moment.</p>
<div class="reason">
<strong>{$reason}</strong>
</div>
<div class="progress-bar">
<div class="progress-bar-inner"></div>
</div>
<p class="info">
Maintenance mode active since <span class="duration">{$startDateStr}</span><br>
<br>
Started <span class="duration">{$durationText}</span> ago<br>
<small>This page will automatically refresh every 15 seconds.</small>
</p>
</div>
</body>
</html>
HTML;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber\UserSystem;
use App\Entity\Parts\Part;

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 - 2026 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\Exceptions;
class ProviderIDNotSupportedException extends \RuntimeException
{
public function fromProvider(string $providerKey, string $id): self
{
return new self(sprintf('The given ID %s is not supported by the provider %s.', $id, $providerKey,));
}
}

View File

@@ -42,15 +42,14 @@ declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
use Twig\Error\Error;
class TwigModeException extends RuntimeException
{
private const PROJECT_PATH = __DIR__ . '/../../';
public function __construct(?Error $previous = null)
public function __construct(?\Throwable $previous = null)
{
parent::__construct($previous->getMessage(), 0, $previous);
parent::__construct($previous?->getMessage() ?? "Unknown message", 0, $previous);
}
/**

View File

@@ -22,17 +22,23 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Attachments\ProjectAttachment;
use App\Services\ElementTypeNameGenerator;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
use App\Services\Attachments\FileTypeFilterTools;
use App\Services\LogSystem\EventCommentNeededHelper;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Translation\StaticMessage;
class AttachmentTypeAdminForm extends BaseEntityAdminForm
{
public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper)
public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper, private readonly ElementTypeNameGenerator $elementTypeNameGenerator)
{
parent::__construct($security, $eventCommentNeededHelper);
}
@@ -41,6 +47,25 @@ class AttachmentTypeAdminForm extends BaseEntityAdminForm
{
$is_new = null === $entity->getID();
$choiceLabel = function (string $class) {
if (!is_a($class, Attachment::class, true)) {
return $class;
}
return new StaticMessage($this->elementTypeNameGenerator->typeLabelPlural($class::ALLOWED_ELEMENT_CLASS));
};
$builder->add('allowed_targets', ChoiceType::class, [
'required' => false,
'choices' => array_values(Attachment::ORM_DISCRIMINATOR_MAP),
'choice_label' => $choiceLabel,
'preferred_choices' => [PartAttachment::class, ProjectAttachment::class],
'label' => 'attachment_type.edit.allowed_targets',
'help' => 'attachment_type.edit.allowed_targets.help',
'multiple' => true,
]);
$builder->add('filetype_filter', TextType::class, [
'required' => false,
'label' => 'attachment_type.edit.filetype_filter',

View File

@@ -71,6 +71,7 @@ class BaseEntityAdminForm extends AbstractType
'label' => 'name.label',
'attr' => [
'placeholder' => 'part.name.placeholder',
'autofocus' => $is_new,
],
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form;
use App\Form\Type\AttachmentTypeType;
use App\Settings\SystemSettings\AttachmentsSettings;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Attachments\Attachment;
@@ -67,10 +68,10 @@ class AttachmentFormType extends AbstractType
'required' => false,
'empty_data' => '',
])
->add('attachment_type', StructuralEntityType::class, [
->add('attachment_type', AttachmentTypeType::class, [
'label' => 'attachment.edit.attachment_type',
'class' => AttachmentType::class,
'disable_not_selectable' => true,
'attachment_filter_class' => $options['data_class'] ?? null,
'allow_add' => $this->security->isGranted('@attachment_types.create'),
]);
@@ -121,9 +122,7 @@ class AttachmentFormType extends AbstractType
],
'constraints' => [
//new AllowedFileExtension(),
new File([
'maxSize' => $options['max_file_size'],
]),
new File(maxSize: $options['max_file_size']),
],
]);

View File

@@ -135,6 +135,10 @@ class PartFilterType extends AbstractType
'min' => 0,
]);
$builder->add('gtin', TextConstraintType::class, [
'label' => 'part.gtin',
]);
$builder->add('measurementUnit', StructuralEntityConstraintType::class, [
'label' => 'part.edit.partUnit',
'entity_class' => MeasurementUnit::class

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form\InfoProviderSystem;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
@@ -61,7 +62,7 @@ class FieldToProviderMappingType extends AbstractType
'style' => 'width: 80px;'
],
'constraints' => [
new \Symfony\Component\Validator\Constraints\Range(['min' => 1, 'max' => 10]),
new Range(min: 1, max: 10),
],
]);
}

View File

@@ -75,7 +75,8 @@ class ScanDialogType extends AbstractType
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp'
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp',
BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin',
},
]);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form\Part;
use App\Form\Type\TriStateCheckboxType;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Supplier;
@@ -73,6 +74,11 @@ class OrderdetailType extends AbstractType
'label' => 'orderdetails.edit.obsolete',
]);
$builder->add('pricesIncludesVAT', TriStateCheckboxType::class, [
'required' => false,
'label' => 'orderdetails.edit.prices_includes_vat',
]);
//Add pricedetails after we know the data, so we can set the default currency
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
/** @var Orderdetail $orderdetail */

View File

@@ -43,6 +43,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\LogSystem\EventCommentNeededHelper;
use App\Services\LogSystem\EventCommentType;
use App\Settings\MiscSettings\IpnSuggestSettings;
use App\Settings\SystemSettings\LocalizationSettings;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -63,6 +64,7 @@ class PartBaseType extends AbstractType
protected UrlGeneratorInterface $urlGenerator,
protected EventCommentNeededHelper $event_comment_needed_helper,
protected IpnSuggestSettings $ipnSuggestSettings,
private readonly LocalizationSettings $localizationSettings,
) {
}
@@ -115,6 +117,7 @@ class PartBaseType extends AbstractType
'label' => 'part.edit.name',
'attr' => [
'placeholder' => 'part.edit.name.placeholder',
'autofocus' => $new_part,
],
])
->add('description', RichTextEditorType::class, [
@@ -216,7 +219,13 @@ class PartBaseType extends AbstractType
'disable_not_selectable' => true,
'label' => 'part.edit.partCustomState',
])
->add('ipn', TextType::class, $ipnOptions);
->add('ipn', TextType::class, $ipnOptions)
->add('gtin', TextType::class, [
'required' => false,
'empty_data' => null,
'label' => 'part.gtin',
])
;
//Comment section
$builder->add('comment', RichTextEditorType::class, [
@@ -261,6 +270,9 @@ class PartBaseType extends AbstractType
'entity' => $part,
]);
$orderdetailPrototype = new Orderdetail();
$orderdetailPrototype->setPricesIncludesVAT($this->localizationSettings->pricesIncludeTaxByDefault);
//Orderdetails section
$builder->add('orderdetails', CollectionType::class, [
'entry_type' => OrderdetailType::class,
@@ -269,7 +281,7 @@ class PartBaseType extends AbstractType
'allow_delete' => true,
'label' => false,
'by_reference' => false,
'prototype_data' => new Orderdetail(),
'prototype_data' => $orderdetailPrototype,
'entry_options' => [
'measurement_unit' => $part->getPartUnit(),
],

View File

@@ -31,6 +31,7 @@ use App\Form\Type\StructuralEntityType;
use App\Form\Type\UserSelectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -110,6 +111,13 @@ class PartLotType extends AbstractType
//Do not remove whitespace chars on the beginning and end of the string
'trim' => false,
]);
$builder->add('last_stocktake_at', DateTimeType::class, [
'label' => 'part_lot.edit.last_stocktake_at',
'widget' => 'single_text',
'disabled' => !$this->security->isGranted('@parts_stock.stocktake'),
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -0,0 +1,56 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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\Type;
use App\Entity\Attachments\AttachmentType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Form type to select the AttachmentType to use in an attachment form. This is used to filter the available attachment types based on the target class.
*/
class AttachmentTypeType extends AbstractType
{
public function getParent(): ?string
{
return StructuralEntityType::class;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->define('attachment_filter_class')->allowedTypes('null', 'string')->default(null);
$resolver->setDefault('class', AttachmentType::class);
$resolver->setDefault('choice_filter', function (Options $options) {
if (is_a($options['class'], AttachmentType::class, true) && $options['attachment_filter_class'] !== null) {
return static function (?AttachmentType $choice) use ($options) {
return $choice?->isAllowedForTarget($options['attachment_filter_class']);
};
}
return null;
});
}
}

View File

@@ -177,10 +177,7 @@ class UserAdminForm extends AbstractType
'required' => false,
'mapped' => false,
'disabled' => !$this->security->isGranted('set_password', $entity) || $entity->isSamlUser(),
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
])
->add('need_pw_change', CheckboxType::class, [

View File

@@ -92,9 +92,7 @@ class UserSettingsType extends AbstractType
'accept' => 'image/*',
],
'constraints' => [
new File([
'maxSize' => '5M',
]),
new File(maxSize: '5M'),
],
])
->add('aboutMe', RichTextEditorType::class, [

View File

@@ -109,6 +109,13 @@ class DBElementRepository extends EntityRepository
return [];
}
//Ensure that all IDs are integers and none is null
foreach ($ids as $id) {
if (!is_int($id)) {
throw new \InvalidArgumentException('Non-integer ID given to findByIDInMatchingOrder: ' . var_export($id, true));
}
}
$cache_key = implode(',', $ids);
//Check if the result is already cached

View File

@@ -58,13 +58,13 @@ final class PartLotVoter extends Voter
{
}
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move', 'stocktake'];
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))
if (in_array($attribute, ['withdraw', 'add', 'move', 'stocktake'], true))
{
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote);

View File

@@ -57,7 +57,7 @@ class UserCacheKeyGenerator
//If the user is null, then treat it as anonymous user.
//When the anonymous user is passed as user then use this path too.
if (!($user instanceof User) || User::ID_ANONYMOUS === $user->getID()) {
return 'user$_'.User::ID_ANONYMOUS;
return 'user$_'.User::ID_ANONYMOUS . '_'.$locale;
}
//Use the unique user id and the locale to generate the key

View File

@@ -189,7 +189,7 @@ class KiCadHelper
"symbolIdStr" => $part->getEdaInfo()->getKicadSymbol() ?? $part->getCategory()?->getEdaInfo()->getKicadSymbol() ?? "",
"exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false),
"exclude_from_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false),
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? true),
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false),
"fields" => []
];

View File

@@ -59,6 +59,7 @@ class PartMerger implements EntityMergerInterface
$this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_number');
$this->useOtherValueIfNotEmtpy($target, $other, 'mass');
$this->useOtherValueIfNotEmtpy($target, $other, 'ipn');
$this->useOtherValueIfNotEmtpy($target, $other, 'gtin');
//Merge relations to other entities
$this->useOtherValueIfNotNull($target, $other, 'manufacturer');
@@ -184,4 +185,4 @@ class PartMerger implements EntityMergerInterface
}
}
}
}
}

View File

@@ -22,6 +22,8 @@ declare(strict_types=1);
*/
namespace App\Services\ImportExportSystem;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
@@ -274,6 +276,16 @@ class BOMImporter
$entries_by_key = []; // Track entries by name+part combination
$mapped_entries = []; // Collect all mapped entries for validation
// Fetch suppliers once for efficiency
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
$supplierSPNKeys = [];
$suppliersByName = []; // Map supplier names to supplier objects
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$supplierSPNKeys[] = $supplierName . ' SPN';
$suppliersByName[$supplierName] = $supplier;
}
foreach ($csv->getRecords() as $offset => $entry) {
// Apply field mapping to translate column names
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
@@ -349,6 +361,41 @@ class BOMImporter
}
}
// Try to link existing part based on supplier part number if no Part-DB ID is given
if ($part === null) {
// Check all available supplier SPN fields
foreach ($suppliersByName as $supplierName => $supplier) {
$supplier_spn = null;
if (isset($mapped_entry[$supplierName . ' SPN']) && !empty(trim($mapped_entry[$supplierName . ' SPN']))) {
$supplier_spn = trim($mapped_entry[$supplierName . ' SPN']);
}
if ($supplier_spn !== null) {
// Query for orderdetails with matching supplier and SPN
$orderdetail = $this->entityManager->getRepository(Orderdetail::class)
->findOneBy([
'supplier' => $supplier,
'supplierpartnr' => $supplier_spn,
]);
if ($orderdetail !== null && $orderdetail->getPart() !== null) {
$part = $orderdetail->getPart();
$name = $part->getName(); // Update name with actual part name
$this->logger->info('Linked BOM entry to existing part via supplier SPN', [
'supplier' => $supplierName,
'supplier_spn' => $supplier_spn,
'part_id' => $part->getID(),
'part_name' => $part->getName(),
]);
break; // Stop searching once a match is found
}
}
}
}
// Create unique key for this entry (name + part ID)
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
@@ -400,9 +447,14 @@ class BOMImporter
if (isset($mapped_entry['Manufacturer'])) {
$comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer'];
}
if (isset($mapped_entry['LCSC'])) {
$comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC'];
// Add supplier part numbers dynamically
foreach ($supplierSPNKeys as $spnKey) {
if (isset($mapped_entry[$spnKey]) && !empty($mapped_entry[$spnKey])) {
$comment_parts[] = $spnKey . ': ' . $mapped_entry[$spnKey];
}
}
if (isset($mapped_entry['Supplier and ref'])) {
$comment_parts[] = $mapped_entry['Supplier and ref'];
}
@@ -485,7 +537,7 @@ class BOMImporter
];
// Add dynamic supplier fields based on available suppliers in the database
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$targets[$supplierName . ' SPN'] = [
@@ -520,7 +572,7 @@ class BOMImporter
];
// Add supplier-specific patterns
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$supplierLower = strtolower($supplierName);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Helpers\FilenameSanatizer;
@@ -177,7 +178,7 @@ class EntityExporter
$colIndex = 1;
foreach ($columns as $column) {
$cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
$cellCoordinate = Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
$worksheet->setCellValue($cellCoordinate, $column);
$colIndex++;
}
@@ -265,11 +266,14 @@ class EntityExporter
//Sanitize the filename
$filename = FilenameSanatizer::sanitizeFilename($filename);
//Remove percent for fallback
$fallback = str_replace("%", "_", $filename);
// Create the disposition of the file
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$filename,
u($filename)->ascii()->toString(),
$fallback,
);
// Set the content disposition
$response->headers->set('Content-Disposition', $disposition);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parts\Category;
@@ -419,14 +420,14 @@ class EntityImporter
'worksheet_title' => $worksheet->getTitle()
]);
$highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
$highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);
for ($row = 1; $row <= $highestRow; $row++) {
$rowData = [];
// Read all columns using numeric index
for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) {
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
$col = Coordinate::stringFromColumnIndex($colIndex);
try {
$cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
$rowData[] = $cellValue ?? '';

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem\DTOs;
use Doctrine\ORM\Exception\ORMException;
use App\Entity\Parts\Part;
use Doctrine\ORM\EntityManagerInterface;
use Traversable;
@@ -176,7 +177,7 @@ readonly class BulkSearchResponseDTO implements \ArrayAccess, \IteratorAggregate
* @param array $data
* @param EntityManagerInterface $entityManager
* @return BulkSearchResponseDTO
* @throws \Doctrine\ORM\Exception\ORMException
* @throws ORMException
*/
public static function fromSerializableRepresentation(array $data, EntityManagerInterface $entityManager): BulkSearchResponseDTO
{

View File

@@ -42,6 +42,7 @@ class PartDetailDTO extends SearchResultDTO
?ManufacturingStatus $manufacturing_status = null,
?string $provider_url = null,
?string $footprint = null,
?string $gtin = null,
public readonly ?string $notes = null,
/** @var FileDTO[]|null */
public readonly ?array $datasheets = null,
@@ -68,6 +69,7 @@ class PartDetailDTO extends SearchResultDTO
manufacturing_status: $manufacturing_status,
provider_url: $provider_url,
footprint: $footprint,
gtin: $gtin
);
}
}

View File

@@ -39,7 +39,9 @@ readonly class PriceDTO
public string $price,
/** @var string The currency of the used ISO code of this price detail */
public ?string $currency_iso_code,
/** @var bool If the price includes tax */
/** @var bool If the price includes tax
* @deprecated Use the prices_include_vat property of the PurchaseInfoDTO instead, as this property is not reliable if there are multiple prices with different values for includes_tax
*/
public ?bool $includes_tax = true,
/** @var float the price related quantity */
public ?float $price_related_quantity = 1.0,

View File

@@ -29,6 +29,9 @@ namespace App\Services\InfoProviderSystem\DTOs;
*/
readonly class PurchaseInfoDTO
{
/** @var bool|null If the prices contain VAT or not. Null if state is unknown. */
public ?bool $prices_include_vat;
public function __construct(
public string $distributor_name,
public string $order_number,
@@ -36,6 +39,7 @@ readonly class PurchaseInfoDTO
public array $prices,
/** @var string|null An url to the product page of the vendor */
public ?string $product_url = null,
?bool $prices_include_vat = null,
)
{
//Ensure that the prices are PriceDTO instances
@@ -44,5 +48,17 @@ readonly class PurchaseInfoDTO
throw new \InvalidArgumentException('The prices array must only contain PriceDTO instances');
}
}
//If no prices_include_vat information is given, try to deduct it from the prices
if ($prices_include_vat === null) {
$vatValues = array_unique(array_map(fn(PriceDTO $price) => $price->includes_tax, $this->prices));
if (count($vatValues) === 1) {
$this->prices_include_vat = $vatValues[0]; //Use the value of the prices if they are all the same
} else {
$this->prices_include_vat = null; //If there are different values for the prices, we cannot determine if the prices include VAT or not
}
} else {
$this->prices_include_vat = $prices_include_vat;
}
}
}

View File

@@ -59,6 +59,8 @@ class SearchResultDTO
public readonly ?string $provider_url = null,
/** @var string|null A footprint representation of the providers page */
public readonly ?string $footprint = null,
/** @var string|null The GTIN / EAN of the part */
public readonly ?string $gtin = null,
)
{
if ($preview_image_url !== null) {
@@ -90,6 +92,7 @@ class SearchResultDTO
'manufacturing_status' => $this->manufacturing_status?->value,
'provider_url' => $this->provider_url,
'footprint' => $this->footprint,
'gtin' => $this->gtin,
];
}
@@ -112,6 +115,7 @@ class SearchResultDTO
manufacturing_status: isset($data['manufacturing_status']) ? ManufacturingStatus::tryFrom($data['manufacturing_status']) : null,
provider_url: $data['provider_url'] ?? null,
footprint: $data['footprint'] ?? null,
gtin: $data['gtin'] ?? null,
);
}
}

View File

@@ -94,7 +94,6 @@ final class DTOtoEntityConverter
$entity->setPrice($dto->getPriceAsBigDecimal());
$entity->setPriceRelatedQuantity($dto->price_related_quantity);
//Currency TODO
if ($dto->currency_iso_code !== null) {
$entity->setCurrency($this->getCurrency($dto->currency_iso_code));
} else {
@@ -117,6 +116,8 @@ final class DTOtoEntityConverter
$entity->addPricedetail($this->convertPrice($price));
}
$entity->setPricesIncludesVAT($dto->prices_include_vat);
return $entity;
}
@@ -175,6 +176,8 @@ final class DTOtoEntityConverter
$entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET);
$entity->setManufacturerProductURL($dto->manufacturer_product_url ?? '');
$entity->setGtin($dto->gtin);
//Set the provider reference on the part
$entity->setProviderReference(InfoProviderReference::fromPartDTO($dto));

View File

@@ -82,7 +82,7 @@ final class PartInfoRetriever
protected function searchInProvider(InfoProviderInterface $provider, string $keyword): array
{
//Generate key and escape reserved characters from the provider id
$escaped_keyword = urlencode($keyword);
$escaped_keyword = hash('xxh3', $keyword);
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
//Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
@@ -108,7 +108,7 @@ final class PartInfoRetriever
}
//Generate key and escape reserved characters from the provider id
$escaped_part_id = urlencode($part_id);
$escaped_part_id = hash('xxh3', $part_id);
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
//Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
@@ -145,4 +145,4 @@ final class PartInfoRetriever
return $this->dto_to_entity_converter->convertPart($details);
}
}
}

View File

@@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use App\Services\InfoProviderSystem\Providers\URLHandlerInfoProviderInterface;
/**
* This class keeps track of all registered info providers and allows to find them by their key
@@ -47,6 +48,8 @@ final class ProviderRegistry
*/
private array $providers_disabled = [];
private array $providers_by_domain = [];
/**
* @var bool Whether the registry has been initialized
*/
@@ -78,6 +81,14 @@ final class ProviderRegistry
$this->providers_by_name[$key] = $provider;
if ($provider->isActive()) {
$this->providers_active[$key] = $provider;
if ($provider instanceof URLHandlerInfoProviderInterface) {
foreach ($provider->getHandledDomains() as $domain) {
if (isset($this->providers_by_domain[$domain])) {
throw new \LogicException("Domain $domain is already handled by another provider");
}
$this->providers_by_domain[$domain] = $provider;
}
}
} else {
$this->providers_disabled[$key] = $provider;
}
@@ -139,4 +150,29 @@ final class ProviderRegistry
return $this->providers_disabled;
}
}
public function getProviderHandlingDomain(string $domain): (InfoProviderInterface&URLHandlerInfoProviderInterface)|null
{
if (!$this->initialized) {
$this->initStructures();
}
//Check if the domain is directly existing:
if (isset($this->providers_by_domain[$domain])) {
return $this->providers_by_domain[$domain];
}
//Otherwise check for subdomains:
$parts = explode('.', $domain);
while (count($parts) > 2) {
array_shift($parts);
$check_domain = implode('.', $parts);
if (isset($this->providers_by_domain[$check_domain])) {
return $this->providers_by_domain[$check_domain];
}
}
//If we found nothing, return null
return null;
}
}

View File

@@ -34,7 +34,7 @@ use App\Settings\InfoProviderSystem\BuerklinSettings;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class BuerklinProvider implements BatchInfoProviderInterface
class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProviderInterface
{
private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin';
@@ -365,7 +365,7 @@ class BuerklinProvider implements BatchInfoProviderInterface
* - prefers 'zoom' format, then 'product' format, then all others
*
* @param array|null $images
* @return \App\Services\InfoProviderSystem\DTOs\FileDTO[]
* @return FileDTO[]
*/
private function getProductImages(?array $images): array
{
@@ -636,4 +636,35 @@ class BuerklinProvider implements BatchInfoProviderInterface
);
}
public function getHandledDomains(): array
{
return ['buerklin.com'];
}
public function getIDFromURL(string $url): ?string
{
//Inputs:
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/
//https://www.buerklin.com/de/p/40F1332/
//https://www.buerklin.com/en/p/bkl-electronic/dc-connectors/072341-l/40F1332/
//https://www.buerklin.com/en/p/40F1332/
//The ID is the last part after the manufacturer/category/mpn segment and before the final slash
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/#download should also work
$path = parse_url($url, PHP_URL_PATH);
if (!$path) {
return null;
}
// Ensure it's actually a product URL
if (strpos($path, '/p/') === false) {
return null;
}
$id = basename(rtrim($path, '/'));
return $id !== '' && $id !== 'p' ? $id : null;
}
}

View File

@@ -0,0 +1,347 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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\Services\InfoProviderSystem\Providers;
use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Settings\InfoProviderSystem\ConradSettings;
use App\Settings\InfoProviderSystem\ConradShopIDs;
use Symfony\Contracts\HttpClient\HttpClientInterface;
readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface
{
private const SEARCH_ENDPOINT = '/search/1/v3/facetSearch';
public const DISTRIBUTOR_NAME = 'Conrad';
private HttpClientInterface $httpClient;
public function __construct( HttpClientInterface $httpClient, private ConradSettings $settings)
{
//We want everything in JSON
$this->httpClient = $httpClient->withOptions([
'headers' => [
'Accept' => 'application/json',
],
]);
}
public function getProviderInfo(): array
{
return [
'name' => 'Conrad',
'description' => 'Retrieves part information from conrad.de',
'url' => 'https://www.conrad.de/',
'disabled_help' => 'Set API key in settings',
'settings_class' => ConradSettings::class,
];
}
public function getProviderKey(): string
{
return 'conrad';
}
public function isActive(): bool
{
return !empty($this->settings->apiKey);
}
private function getProductUrl(string $productId): string
{
return 'https://' . $this->settings->shopID->getDomain() . '/' . $this->settings->shopID->getLanguage() . '/p/' . $productId;
}
private function getFootprintFromTechnicalDetails(array $technicalDetails): ?string
{
foreach ($technicalDetails as $detail) {
if ($detail['name'] === 'ATT_LOV_HOUSING_SEMICONDUCTORS') {
return $detail['values'][0] ?? null;
}
}
return null;
}
public function searchByKeyword(string $keyword): array
{
$url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/'
. $this->settings->shopID->getDomainEnd() . '/' . $this->settings->shopID->getLanguage()
. '/' . $this->settings->shopID->getCustomerType();
$response = $this->httpClient->request('POST', $url, [
'query' => [
'apikey' => $this->settings->apiKey,
],
'json' => [
'query' => $keyword,
'size' => 50,
'sort' => [["field"=>"_score","order"=>"desc"]],
],
]);
$out = [];
$results = $response->toArray();
foreach($results['hits'] as $result) {
$out[] = new SearchResultDTO(
provider_key: $this->getProviderKey(),
provider_id: $result['productId'],
name: $result['manufacturerId'] ?? $result['productId'],
description: $result['title'] ?? '',
manufacturer: $result['brand']['name'] ?? null,
mpn: $result['manufacturerId'] ?? null,
preview_image_url: $result['image'] ?? null,
provider_url: $this->getProductUrl($result['productId']),
footprint: $this->getFootprintFromTechnicalDetails($result['technicalDetails'] ?? []),
gtin: $result['ean'] ?? null,
);
}
return $out;
}
private function getFootprintFromTechnicalAttributes(array $technicalDetails): ?string
{
foreach ($technicalDetails as $detail) {
if ($detail['attributeID'] === 'ATT.LOV.HOUSING_SEMICONDUCTORS') {
return $detail['values'][0]['value'] ?? null;
}
}
return null;
}
/**
* @param array $technicalAttributes
* @return array<ParameterDTO>
*/
private function technicalAttributesToParameters(array $technicalAttributes): array
{
return array_map(static function (array $p) {
if (count($p['values']) === 1) { //Single value attribute
if (array_key_exists('unit', $p['values'][0])) {
return ParameterDTO::parseValueField( //With unit
name: $p['attributeName'],
value: $p['values'][0]['value'],
unit: $p['values'][0]['unit']['name'],
);
}
return ParameterDTO::parseValueIncludingUnit(
name: $p['attributeName'],
value: $p['values'][0]['value'],
);
}
if (count($p['values']) === 2) { //Multi value attribute (e.g. min/max)
$value = $p['values'][0]['value'] ?? null;
$value2 = $p['values'][1]['value'] ?? null;
$unit = $p['values'][0]['unit']['name'] ?? '';
$unit2 = $p['values'][1]['unit']['name'] ?? '';
if ($unit === $unit2 && is_numeric($value) && is_numeric($value2)) {
if (array_key_exists('unit', $p['values'][0])) { //With unit
return new ParameterDTO(
name: $p['attributeName'],
value_min: (float)$value,
value_max: (float)$value2,
unit: $unit,
);
}
return new ParameterDTO(
name: $p['attributeName'],
value_min: (float)$value,
value_max: (float)$value2,
);
}
}
// fallback implementation
$values = implode(", ", array_map(fn($q) =>
array_key_exists('unit', $q) ? $q['value']." ". ($q['unit']['name'] ?? $q['unit']) : $q['value']
, $p['values']));
return ParameterDTO::parseValueIncludingUnit(
name: $p['attributeName'],
value: $values,
);
}, $technicalAttributes);
}
/**
* @param array $productMedia
* @return array<FileDTO>
*/
public function productMediaToDatasheets(array $productMedia): array
{
$files = [];
foreach ($productMedia['manuals'] as $manual) {
//Filter out unwanted languages
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
continue;
}
$files[] = new FileDTO($manual['fullUrl'], $manual['title'] . ' (' . $manual['language'] . ')');
}
return $files;
}
/**
* Queries prices for a given product ID. It makes a POST request to the Conrad API
* @param string $productId
* @return PurchaseInfoDTO
*/
private function queryPrices(string $productId): PurchaseInfoDTO
{
$priceQueryURL = $this->settings->shopID->getAPIRoot() . '/price-availability/4/'
. $this->settings->shopID->getShopID() . '/facade';
$response = $this->httpClient->request('POST', $priceQueryURL, [
'query' => [
'apikey' => $this->settings->apiKey,
'overrideCalculationSchema' => $this->settings->includeVAT ? 'GROSS' : 'NET'
],
'json' => [
'ns:inputArticleItemList' => [
"#namespaces" => [
"ns" => "http://www.conrad.de/ccp/basit/service/article/priceandavailabilityservice/api"
],
'articles' => [
[
"articleID" => $productId,
"calculatePrice" => true,
"checkAvailability" => true,
],
]
]
]
]);
$result = $response->toArray();
$priceInfo = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['price'] ?? [];
$price = $priceInfo['price'] ?? "0.0";
$currency = $priceInfo['currency'] ?? "EUR";
$includesVat = !$priceInfo['isGrossAmount'] || $priceInfo['isGrossAmount'] === "true";
$minOrderAmount = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['availabilityStatus']['minimumOrderQuantity'] ?? 1;
$prices = [];
foreach ($priceInfo['priceScale'] ?? [] as $priceScale) {
$prices[] = new PriceDTO(
minimum_discount_amount: max($priceScale['scaleFrom'], $minOrderAmount),
price: (string)$priceScale['pricePerUnit'],
currency_iso_code: $currency,
includes_tax: $includesVat
);
}
if (empty($prices)) { //Fallback if no price scales are defined
$prices[] = new PriceDTO(
minimum_discount_amount: $minOrderAmount,
price: (string)$price,
currency_iso_code: $currency,
includes_tax: $includesVat
);
}
return new PurchaseInfoDTO(
distributor_name: self::DISTRIBUTOR_NAME,
order_number: $productId,
prices: $prices,
product_url: $this->getProductUrl($productId)
);
}
public function getDetails(string $id): PartDetailDTO
{
$productInfoURL = $this->settings->shopID->getAPIRoot() . '/product/1/service/' . $this->settings->shopID->getShopID()
. '/product/' . $id;
$response = $this->httpClient->request('GET', $productInfoURL, [
'query' => [
'apikey' => $this->settings->apiKey,
]
]);
$data = $response->toArray();
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $data['shortProductNumber'],
name: $data['productFullInformation']['manufacturer']['name'] ?? $data['productFullInformation']['manufacturer']['id'] ?? $data['shortProductNumber'],
description: $data['productShortInformation']['title'] ?? '',
category: $data['productShortInformation']['articleGroupName'] ?? null,
manufacturer: $data['brand']['displayName'] !== null ? preg_replace("/[\u{2122}\u{00ae}]/", "", $data['brand']['displayName']) : null, //Replace ™ and ® symbols
mpn: $data['productFullInformation']['manufacturer']['id'] ?? null,
preview_image_url: $data['productShortInformation']['mainImage']['imageUrl'] ?? null,
provider_url: $this->getProductUrl($data['shortProductNumber']),
footprint: $this->getFootprintFromTechnicalAttributes($data['productFullInformation']['technicalAttributes'] ?? []),
gtin: $data['productFullInformation']['eanCode'] ?? null,
notes: $data['productFullInformation']['description'] ?? null,
datasheets: $this->productMediaToDatasheets($data['productMedia'] ?? []),
parameters: $this->technicalAttributesToParameters($data['productFullInformation']['technicalAttributes'] ?? []),
vendor_infos: [$this->queryPrices($data['shortProductNumber'])]
);
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::PICTURE,
ProviderCapabilities::DATASHEET,
ProviderCapabilities::PRICE,
ProviderCapabilities::FOOTPRINT,
ProviderCapabilities::GTIN,
];
}
public function getHandledDomains(): array
{
$domains = [];
foreach (ConradShopIDs::cases() as $shopID) {
$domains[] = $shopID->getDomain();
}
return array_unique($domains);
}
public function getIDFromURL(string $url): ?string
{
//Input: https://www.conrad.de/de/p/apple-iphone-air-wolkenweiss-256-gb-eek-a-a-g-16-5-cm-6-5-zoll-3475299.html
//The numbers before the optional .html are the product ID
$matches = [];
if (preg_match('/-(\d+)(\.html)?$/', $url, $matches) === 1) {
return $matches[1];
}
return null;
}
}

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