Compare commits

..

110 Commits

Author SHA1 Message Date
Jan Böhmer
76295b73c8 Bumped to release 1.9.0 2023-11-25 20:07:51 +01:00
Jan Böhmer
8c00769757 Merge remote-tracking branch 'origin/l10n_master' 2023-11-25 20:07:17 +01:00
Jan Böhmer
773d0e9d20 New translations messages.en.xlf (English) 2023-11-25 20:05:34 +01:00
Jan Böhmer
d14f596479 New translations messages.en.xlf (German) 2023-11-25 20:05:23 +01:00
Jan Böhmer
700ed42ce5 Removed unused translation 2023-11-25 20:00:08 +01:00
Jan Böhmer
dc2369c71e Remove additional colon in translation 2023-11-25 19:42:32 +01:00
Jan Böhmer
5fc760f6ad Run phpunit tests against PHP 8.3 2023-11-25 19:42:05 +01:00
Jan Böhmer
ffb5d3e790 Added an checkbox in the withdrawal/move dialog, that if checked automatically deletes a part lot if it becomes empty during the operation
Fixes issue #327
2023-11-25 19:38:21 +01:00
Jan Böhmer
512947e0d0 New translations messages.en.xlf (English) 2023-11-25 19:20:46 +01:00
Jan Böhmer
9e69a09a19 Fixed translation of stock change type in log table 2023-11-25 19:16:54 +01:00
Jan Böhmer
b447a69dae Allow to specify an informational field during a part stock operation, where the user can specify, when this operation was really performed
Fixes issue #416
2023-11-25 19:10:18 +01:00
Jan Böhmer
d52e6b5881 New translations messages.en.xlf (German) 2023-11-25 14:00:18 +01:00
Jan Böhmer
6cff19358a Dont show the permission reset warning during database migration anymore
This only affects database which were older than the new permission system  and hopefully everybody should have updated by now. The message could irritate new users, so it was removed
2023-11-25 13:21:39 +01:00
Jan Böhmer
a6d476f953 New translations messages.en.xlf (German) 2023-11-25 13:00:19 +01:00
Jan Böhmer
aba73174ab Fixed wrong link in docs. 2023-11-25 01:48:32 +01:00
Jan Böhmer
83d43d931c Added a SAML_BEHIND_PROXY env, which must be set if Part-DB is behind a reverse proxy when using SAML
The php-saml library use its own logic to determine the current path, and we need to set the use_proxy_vars so that  it respects the proxy FORWARDED headers.

This should fix issue #436
2023-11-25 01:45:08 +01:00
Jan Böhmer
64cebaba77 New translations messages.en.xlf (English) 2023-11-25 01:20:54 +01:00
Jan Böhmer
07535c26a6 Fixed static analysis issue 2023-11-25 01:19:48 +01:00
Jan Böhmer
aab1dcf8e6 Fixed APIPlatform test issues which were introduced with the upgrade to api platform core v3.2.6 2023-11-25 01:17:35 +01:00
Jan Böhmer
4b88de9316 Show a notice above the permission editor, that permissions can depend on each other
Related to issue #435
2023-11-25 00:42:17 +01:00
Jan Böhmer
84c111ac7c Show a warning flash message, if permissions were corrected and missing permissions were set
Related to issue #435
2023-11-25 00:36:31 +01:00
Jan Böhmer
2feeb1c868 New translations messages.en.xlf (English) 2023-11-25 00:01:18 +01:00
Jan Böhmer
17000da97e Updated dependencies 2023-11-24 23:55:21 +01:00
Jan Böhmer
5b09cbf1ac Merge branch 'merge_system' 2023-11-24 23:51:35 +01:00
Jan Böhmer
07088c94e7 Implemented logic for not (yet) used EntityMerger service 2023-11-24 23:48:39 +01:00
Jan Böhmer
1da5e7ccd7 Properly merge the PartAssociations pointing towards the parts 2023-11-24 23:36:09 +01:00
Jan Böhmer
b9956e38b8 Dont concat if one of the strings is empty during part merge 2023-11-24 23:16:26 +01:00
Jan Böhmer
36879dd7da Test merge and update from info provider controller endpoints 2023-11-24 23:13:15 +01:00
Jan Böhmer
099ea63740 Show a notice flash to remember user to review changes 2023-11-24 19:45:15 +01:00
Jan Böhmer
615defa84a Give the update part from info provider menu a own design 2023-11-24 19:35:44 +01:00
Jan Böhmer
3eeeb01ad1 Added possibility to search the info providers to update an existing part 2023-11-24 19:28:30 +01:00
Jan Böhmer
73f6d79925 Added an modal form on the part info page, to merge a part into another one 2023-11-22 22:50:25 +01:00
Jan Böhmer
b0f5d9b55f Added an form for merging two parts together 2023-11-22 20:11:38 +01:00
Jan Böhmer
50069c7611 Fixed tests 2023-11-22 17:14:24 +01:00
Jan Böhmer
c86694ab8f Merge the remaining fields of a Part 2023-11-21 19:41:18 +01:00
Jan Böhmer
478d5e2a3a Merge tags and bool fields of parts 2023-11-21 00:18:10 +01:00
Jan Böhmer
e7b766906d Try to avoid duplications during merging of parts and merge more fields. 2023-11-21 00:02:17 +01:00
Jan Böhmer
c5435df6f9 Add a blue dot to the collection type delete buttons in forms to indicate that this element was not yet saved to DB yet 2023-11-20 23:40:14 +01:00
Jan Böhmer
e8f4cd9fec Merge remote-tracking branch 'origin/l10n_master' 2023-11-20 00:08:20 +01:00
Jan Böhmer
378d695a24 New translations validators.en.xlf (German) 2023-11-20 00:06:55 +01:00
Jan Böhmer
a4b16f7f09 New translations messages.en.xlf (German) 2023-11-20 00:06:54 +01:00
Jan Böhmer
1fe3a614c9 New translations validators.en.xlf (German) 2023-11-20 00:01:40 +01:00
Jan Böhmer
773e393f55 New translations messages.en.xlf (German) 2023-11-20 00:01:39 +01:00
Jan Böhmer
87626589a3 Added very basic controller to merge info provider data into the part 2023-11-19 23:47:46 +01:00
Jan Böhmer
01784a9d1f Started implementing building blocks for the merge system 2023-11-19 23:17:48 +01:00
Jan Böhmer
f99323f9b3 New translations messages.en.xlf (English) 2023-11-19 22:22:04 +01:00
Jan Böhmer
83ad99215f Added optional "stocked amount" and storage locations columns for the BOM list
This fixes issue #429
2023-11-19 22:13:25 +01:00
Jan Böhmer
958d59a0ff Save search setting checkboxes state to localStorage to persist it
Related to issue #424
2023-11-19 21:49:16 +01:00
Jan Böhmer
de8a68c70d Expliticly declare a conversion from the custom TinyInt doctrine type to int
This hopefully fixes issue #434
2023-11-19 21:03:43 +01:00
Jan Böhmer
5f87d5b1ac Updated dependencies 2023-11-19 21:02:43 +01:00
Jan Böhmer
c2ea880dad New translations validators.en.xlf (Italian) 2023-11-18 09:40:18 +01:00
Jan Böhmer
7eba4254e6 New translations messages.en.xlf (Italian) 2023-11-18 09:40:17 +01:00
Jan Böhmer
76bb3eae9d New translations messages.en.xlf (Italian) 2023-11-18 08:40:18 +01:00
Jan Böhmer
3da656c08b Fixed (false positive) test in APIDocsAvailabilityTest
API Platform seems to have slightly changed the serialization process of errors and the requested HTML format was not available for error serialization
2023-11-17 23:59:32 +01:00
Jan Böhmer
b6dc3eb1a2 New translations validators.en.xlf (English) 2023-11-17 23:51:17 +01:00
Jan Böhmer
fefa65941b New translations messages.en.xlf (English) 2023-11-17 23:51:16 +01:00
Jan Böhmer
74d75c6e1f Upgraded dependencies 2023-11-17 23:44:28 +01:00
Jan Böhmer
01ed3eeecd Merge branch 'part_associations' 2023-11-17 23:37:26 +01:00
Jan Böhmer
9a3b9b84bc Fixed deprecation 2023-11-17 23:32:41 +01:00
Jan Böhmer
90a1ffa2ac Fixed issues with wrong Groups attribute definition 2023-11-17 23:30:36 +01:00
Jan Böhmer
5442aa5e07 Added test for vendor barcode scanner 2023-11-17 23:29:06 +01:00
Jan Böhmer
0ab604d468 Added migrations for sqlite 2023-11-17 23:26:45 +01:00
Jan Böhmer
0b178b46f2 Allow to scan barcodes, whose content where defined in the vendor_barcode field 2023-11-17 23:23:54 +01:00
Jan Böhmer
d12bde2b1e Reveal the invalid field in a collapse if a validation error occurs 2023-11-17 23:15:18 +01:00
Jan Böhmer
96a771e7ac Allow to edit the vendor_barcode field of a PartLot 2023-11-17 23:05:47 +01:00
Jan Böhmer
3e6b80d1cf Added possibility to access PartAssociations via API 2023-11-17 22:12:19 +01:00
Jan Böhmer
4d7d196a3c Added some documentation and tests to PartAssociations 2023-11-17 18:36:49 +01:00
Jan Böhmer
4e1f6277c6 Added a translation for the part association in LogFilterType 2023-11-17 18:28:24 +01:00
Jan Böhmer
626c4dd5d6 Use a custom delete confirmation message for part associations 2023-11-16 22:38:01 +01:00
Jan Böhmer
c8bd800b9f Hide the custom type input field if not needed to improve the UX 2023-11-16 22:33:31 +01:00
d-buchmann
0fa03d8bb0 Associate project BOM entries with correct project when cloning (#433) 2023-11-16 12:56:42 +01:00
Jan Böhmer
22606f01d2 New translations messages.en.xlf (Chinese Simplified) 2023-11-16 10:10:33 +01:00
Jan Böhmer
3c2e535117 New translations messages.en.xlf (Chinese Simplified) 2023-11-16 09:10:25 +01:00
Jan Böhmer
7f612bc371 Added ability to view part relations on a part info page 2023-11-15 00:44:45 +01:00
Jan Böhmer
cc2332a83a Added some constraints to ensure validity of the PartAssociation entities 2023-11-14 23:50:09 +01:00
Jan Böhmer
c7892cb9e2 Added a seperate field for specifying the other relation 2023-11-14 23:35:18 +01:00
Jan Böhmer
5bd2d9b344 Restrict size of part images, if the part selector element is very wide
This improves UX
2023-11-13 23:05:25 +01:00
Jan Böhmer
81f8b365e9 Improved the association edit panel 2023-11-13 23:01:59 +01:00
Jan Böhmer
8ab9cf1417 Added very basic possibility to add an association 2023-11-13 00:11:58 +01:00
Jan Böhmer
b7cfdebad5 Added data field for vendor PartLot barcodes 2023-11-12 22:06:05 +01:00
Jan Böhmer
0447a7e6b3 Added basic data structures for part associations 2023-11-12 21:53:45 +01:00
Jan Böhmer
6d67ee8106 New translations security.en.xlf (English) 2023-11-12 00:40:45 +01:00
Jan Böhmer
2d7058329c New translations validators.en.xlf (English) 2023-11-12 00:40:44 +01:00
Jan Böhmer
9e58baa574 New translations messages.en.xlf (English) 2023-11-12 00:40:43 +01:00
Jan Böhmer
6d8cb9cc08 New translations messages.en.xlf (French) 2023-11-12 00:40:28 +01:00
Jan Böhmer
5cfccab671 Allow to scan IPN barcodes using the built in barcode scanner
This improves issue #373
2023-11-12 00:36:13 +01:00
Jan Böhmer
3953e36921 Include the ipv6 localhost in default TRUSTED_PROXIES
Related to issue #428
2023-11-11 23:22:30 +01:00
Jan Böhmer
7163df6d46 Fixed paths generated using app.request.requestUri when in a reverse proxy with a subpath
Related to issue #428
2023-11-11 23:14:22 +01:00
Jan Böhmer
5f86253b94 Add documentation on how to put Part-DB into a subpath of a reverse proxy
This fixes issue #428
2023-11-11 23:03:29 +01:00
Jan Böhmer
93d0f97cfd Updated dependencies 2023-11-11 18:35:49 +01:00
Jan Böhmer
9732b71f85 Fixed other placeholders besides the IPN Barcodes 2023-11-05 21:42:39 +01:00
Jan Böhmer
cf11320789 Keep query parameters when switching language via navbar 2023-11-05 21:32:45 +01:00
Jan Böhmer
5e326bca12 Added label placeholders to add a barcode version of the IPN to a label
This fixes issue #373
2023-11-05 21:24:21 +01:00
Jan Böhmer
3c52e57a44 Updated dependencies 2023-11-04 20:44:32 +01:00
Jan Böhmer
2002b9d5d3 Merge remote-tracking branch 'origin/master' 2023-11-03 23:07:58 +01:00
Jan Böhmer
323c70393d Use demo.part-db.de in links to demo as this now has TLS encryption too. 2023-11-03 23:07:51 +01:00
Jan Böhmer
eabd03dc53 Fixed variable access curly brackets deprecations in migrations 2023-11-03 23:04:55 +01:00
Jan Böhmer
3ac82cf76a The frontend dependencies can now detect their path automatically.
Therefore it does not need to be configured in the webpack.config.js before compilation. This should help to make things like issue #426 easier to resolve.
2023-11-03 23:01:23 +01:00
Pyromane
1409d19922 Update installation_docker.md (#425)
For the database additionally "restart: unless-stopped" is specified, so that this is also available after a restart of the host.
2023-10-31 13:32:43 +01:00
Jan Böhmer
bdcd51d533 Refactored barcode scan functions
This is preparatory work for issue #373
2023-10-26 22:23:43 +02:00
Jan Böhmer
563edb1731 Added ipn to possible columns in project BOM view
This fixes issue #418
2023-10-26 17:55:03 +02:00
dependabot[bot]
cd7013f776 Bump actions/setup-node from 3 to 4 (#420)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-26 17:44:42 +02:00
Jan Böhmer
783a00ca2f Added test for price related quantity in DTOToEntityConverter 2023-10-26 17:41:26 +02:00
Jan Böhmer
e233940f1f Merge remote-tracking branch 'd-buchmann/d-buchmann-patch-1' 2023-10-26 17:38:10 +02:00
Jan Böhmer
717a9fb0a3 Fixed API Platform deprecations 2023-10-25 20:40:41 +02:00
buchmann
5144b75ed7 Fix image type alt name if newly generated 2023-10-25 15:56:40 +02:00
buchmann
aeed7c0802 fix line breaks 2023-10-25 14:29:18 +02:00
buchmann
2b470e6cdd Merge commit 'f8ccd5bc22bbc25188077dc20f0049f7068852ea' into d-buchmann-patch-1 2023-10-25 14:10:46 +02:00
d-buchmann
e6870c61ee Update DTOtoEntityConverter.php 2023-10-25 14:01:46 +02:00
d-buchmann
f8ccd5bc22 Add price_related_quantity to PriceDTO.php 2023-10-25 13:58:59 +02:00
137 changed files with 7695 additions and 3511 deletions

View File

@@ -34,7 +34,7 @@
PassEnv ERROR_PAGE_ADMIN_EMAIL ERROR_PAGE_SHOW_HELP
PassEnv DEMO_MODE NO_URL_REWRITE_AVAILABLE FIXER_API_KEY BANNER
# In old version the SAML sp private key env, was wrongly named SAMLP_SP_PRIVATE_KEY, keep it for backward compatibility
PassEnv SAML_ENABLED SAML_ROLE_MAPPING SAML_UPDATE_GROUP_ON_LOGIN SAML_IDP_ENTITY_ID SAML_IDP_SINGLE_SIGN_ON_SERVICE SAML_IDP_SINGLE_LOGOUT_SERVICE SAML_IDP_X509_CERT SAML_SP_ENTITY_ID SAML_SP_X509_CERT SAML_SP_PRIVATE_KEY SAMLP_SP_PRIVATE_KEY
PassEnv SAML_ENABLED SAML_BEHIND_PROXY SAML_ROLE_MAPPING SAML_UPDATE_GROUP_ON_LOGIN SAML_IDP_ENTITY_ID SAML_IDP_SINGLE_SIGN_ON_SERVICE SAML_IDP_SINGLE_LOGOUT_SERVICE SAML_IDP_X509_CERT SAML_SP_ENTITY_ID SAML_SP_X509_CERT SAML_SP_PRIVATE_KEY SAMLP_SP_PRIVATE_KEY
PassEnv TABLE_DEFAULT_PAGE_SIZE TABLE_PARTS_DEFAULT_COLUMNS
PassEnv PROVIDER_DIGIKEY_CLIENT_ID PROVIDER_DIGIKEY_SECRET PROVIDER_DIGIKEY_CURRENCY PROVIDER_DIGIKEY_LANGUAGE PROVIDER_DIGIKEY_COUNTRY

5
.env
View File

@@ -163,6 +163,9 @@ PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE='true'
# Set this to 1 to enable SAML single sign on
SAML_ENABLED=0
# Set to 1, if your Part-DB installation is behind a reverse proxy and you want to use SAML
SAML_BEHIND_PROXY=0
# A JSON encoded array of role mappings in the form { "saml_role": PARTDB_GROUP_ID, "*": PARTDB_GROUP_ID }
# The first match is used, so the order is important! Put the group mapping with the most privileges first.
# Please not to only use single quotes to enclose the JSON string
@@ -214,7 +217,7 @@ APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
# Set the trusted IPs here, when using an reverse proxy
#TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
#TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
#TRUSTED_HOSTS='^(localhost|example\.com)$'

View File

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

View File

@@ -13,12 +13,11 @@ on:
jobs:
phpunit:
name: PHPUnit and coverage Test (PHP ${{ matrix.php-versions }}, ${{ matrix.db-type }})
# Ubuntu 20.04 ships MySQL 8.0 which causes problems with login, so we just use ubuntu 18.04 for now...
runs-on: ubuntu-22.04
strategy:
matrix:
php-versions: [ '8.1', '8.2' ]
php-versions: [ '8.1', '8.2', '8.3' ]
db-type: [ 'mysql', 'sqlite' ]
env:
@@ -87,7 +86,7 @@ jobs:
run: composer install --prefer-dist --no-progress
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '18'

View File

@@ -9,7 +9,7 @@
![Docker Build Status](https://github.com/Part-DB/Part-DB-symfony/workflows/Docker%20Image%20Build/badge.svg)
[![Crowdin](https://badges.crowdin.net/e/8325196085d4bee8c04b75f7c915452a/localized.svg)](https://part-db.crowdin.com/part-db)
**[Documentation](https://docs.part-db.de/)** | **[Demo](https://part-db.herokuapp.com)** | **[Docker Image](https://hub.docker.com/r/jbtronics/part-db1)**
**[Documentation](https://docs.part-db.de/)** | **[Demo](https://demo.part-db.de/)** | **[Docker Image](https://hub.docker.com/r/jbtronics/part-db1)**
# Part-DB
@@ -24,8 +24,8 @@ for everybody.
## Demo
If you want to test Part-DB without installing it, you can use [this](https://part-db.herokuapp.com) Heroku instance.
(Or this link for the [German Version](https://part-db.herokuapp.com/de/)).
If you want to test Part-DB without installing it, you can use [this](https://demo.part-db.de/) Heroku instance.
(Or this link for the [German Version](https://demo.part-db.de/de/)).
You can log in with username: *user* and password: *user*.
@@ -101,24 +101,20 @@ for a detailed guide how to install Part-DB.**
In bigger instances with concurrent accesses, MySQL is more performant. This can not be changed easily later, so
choose wisely.
4. Install composer dependencies and generate autoload files: `composer install -o --no-dev`
5. If you have put Part-DB into a subdirectory on your server (like `part-db/`), you have to edit the file
`webpack.config.js` and uncomment the lines (remove the `//` before the lines) `.setPublicPath('/part-db/build')` (
line 43) and
`.setManifestKeyPrefix('build/')` (line 44). You have to replace `/part-db` with your own path on line 44.
6. Install client side dependencies and build it: `yarn install` and `yarn build`
7. _Optional_ (speeds up first load): Warmup cache: `php bin/console cache:warmup`
8. Upgrade database to new scheme (or create it, when it was empty): `php bin/console doctrine:migrations:migrate` and
5. Install client side dependencies and build it: `yarn install` and `yarn build`
6. _Optional_ (speeds up first load): Warmup cache: `php bin/console cache:warmup`
7. Upgrade database to new scheme (or create it, when it was empty): `php bin/console doctrine:migrations:migrate` and
follow the instructions given. During the process the password for the admin is user is shown. Copy it. **Caution**:
This steps tamper with your database and could potentially destroy it. So make sure to make a backup of your
database.
9. You can configure Part-DB via `config/parameters.yaml`. You should check if settings match your expectations, after
8. You can configure Part-DB via `config/parameters.yaml`. You should check if settings match your expectations, after
you installed/upgraded Part-DB. Check if `partdb.default_currency` matches your mainly used currency (this can not be
changed after creating price information).
Run `php bin/console cache:clear` when you changed something.
10. Access Part-DB in your browser (under the URL you put it) and login with user *admin*. Password is the one outputted
during DB setup.
If you can not remember the password, set a new one with `php bin/console app:set-password admin`. You can create
new users with the admin user and start using Part-DB.
9. Access Part-DB in your browser (under the URL you put it) and login with user *admin*. Password is the one outputted
during DB setup.
If you can not remember the password, set a new one with `php bin/console app:set-password admin`. You can create
new users with the admin user and start using Part-DB.
When you want to upgrade to a newer version, then just copy the new files into the folder
and repeat the steps 4. to 7.

View File

@@ -1 +1 @@
1.8.2
1.9.0

View File

@@ -85,6 +85,9 @@ const PLACEHOLDERS = [
['[[COMMENT_T]]', 'Comment (plain text)'],
['[[LAST_MODIFIED]]', 'Last modified datetime'],
['[[CREATION_DATE]]', 'Creation datetime'],
['[[IPN_BARCODE_QR]]', 'IPN as QR code'],
['[[IPN_BARCODE_C128]]', 'IPN as Code 128 barcode'],
['[[IPN_BARCODE_C39]]', 'IPN as Code 39 barcode'],
]
},
{

View File

@@ -48,6 +48,9 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
'Comment (plain text)': 'Kommentar (Nur-Text)',
'Last modified datetime': 'Zuletzt geändert',
'Creation datetime': 'Erstellt',
'IPN as QR code': 'IPN als QR Code',
'IPN as Code 128 barcode': 'IPN als Code 128 Barcode',
'IPN as Code 39 barcode': 'IPN als Code 39 Barcode',
'Lot ID': 'Lot ID',
'Lot name': 'Lot Name',

View File

@@ -43,7 +43,8 @@ export default class extends Controller
const message = this.element.dataset.deleteMessage;
const title = this.element.dataset.deleteTitle;
const form = this.element;
//Use event target, to find the form, where the submit button was clicked
const form = event.target;
const submitter = event.submitter;
const that = this;

View File

@@ -0,0 +1,67 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Controller} from "@hotwired/stimulus";
export default class extends Controller
{
static values = {
id: String
}
connect() {
this.loadState()
this.element.addEventListener('change', () => {
this.saveState()
});
}
loadState() {
let storageKey = this.getStorageKey();
let value = localStorage.getItem(storageKey);
if (value === null) {
return;
}
if (value === 'true') {
this.element.checked = true
}
if (value === 'false') {
this.element.checked = false
}
}
saveState() {
let storageKey = this.getStorageKey();
if (this.element.checked) {
localStorage.setItem(storageKey, 'true');
} else {
localStorage.setItem(storageKey, 'false');
}
}
getStorageKey() {
if (this.hasIdValue) {
return 'persistent_checkbox_' + this.idValue
}
return 'persistent_checkbox_' + this.element.id;
}
}

View File

@@ -27,7 +27,7 @@ export default class extends Controller {
}
let tmp = '<div class="row m-0">' +
"<div class='col-2 p-0 d-flex align-items-center'>" +
"<div class='col-2 p-0 d-flex align-items-center' style='max-width: 80px;'>" +
(data.image ? "<img class='typeahead-image' src='" + data.image + "'/>" : "") +
"</div>" +
"<div class='col-10'>" +

View File

@@ -0,0 +1,44 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Controller} from "@hotwired/stimulus";
export default class extends Controller {
static targets = [ "display", "select" ]
connect()
{
this.update();
this.selectTarget.addEventListener('change', this.update.bind(this));
}
update()
{
//If the select value is 0, then we show the input field
if( this.selectTarget.value === '0')
{
this.displayTarget.classList.remove('d-none');
}
else
{
this.displayTarget.classList.add('d-none');
}
}
}

View File

@@ -0,0 +1,68 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Controller} from "@hotwired/stimulus";
export default class extends Controller
{
static targets = ['link', 'mode', 'otherSelect'];
static values = {
targetId: Number,
};
connect() {
}
update() {
const link = this.linkTarget;
const other_select = this.otherSelectTarget;
//Extract the mode using the mode radio buttons (we filter the array to get the checked one)
const mode = (this.modeTargets.filter((e)=>e.checked))[0].value;
if (other_select.value === '') {
link.classList.add('disabled');
return;
}
//Extract href template from data attribute on link target
let href = link.getAttribute('data-href-template');
let target, other;
if (mode === '1') {
target = this.targetIdValue;
other = other_select.value;
} else if (mode === '2') {
target = other_select.value;
other = this.targetIdValue;
} else {
throw 'Invalid mode';
}
//Replace placeholder with actual target id
href = href.replace('__target__', target);
//Replace placeholder with selected value of the select (the event sender)
href = href.replace('__other__', other);
//Assign new href to link
link.setAttribute('href', href);
//Make link clickable
link.classList.remove('disabled');
}
}

View File

@@ -19,7 +19,7 @@
"use strict";
import {Tab, Dropdown} from "bootstrap";
import {Tab, Dropdown, Collapse} from "bootstrap";
import tab from "bootstrap/js/src/tab";
/**
@@ -54,6 +54,7 @@ class TabRememberHelper {
const first_element = merged[0] ?? null;
if(first_element) {
this.revealElementOnTab(first_element);
this.revealElementInCollapse(first_element);
}
}
@@ -62,10 +63,20 @@ class TabRememberHelper {
* @param event
*/
onInvalid(event) {
this.revealElementInCollapse(event.target);
this.revealElementOnTab(event.target);
this.revealElementInDropdown(event.target);
}
revealElementInCollapse(element) {
let collapse = element.closest('.collapse');
if(collapse) {
let bs_collapse = Collapse.getOrCreateInstance(collapse);
bs_collapse.show();
}
}
revealElementInDropdown(element) {
let dropdown = element.closest('.dropdown-menu');

View File

@@ -70,6 +70,7 @@
"symfony/runtime": "6.3.*",
"symfony/security-bundle": "6.3.*",
"symfony/serializer": "6.3.*",
"symfony/string": "6.3.*",
"symfony/translation": "6.3.*",
"symfony/twig-bundle": "6.3.*",
"symfony/ux-translator": "^2.10",

1453
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@ api_platform:
# eager_loading:
# max_joins: 100
keep_legacy_inflector: false
swagger:
api_keys:
# overridden in OpenApiFactoryDecorator

View File

@@ -5,6 +5,7 @@ parameters:
saml.sp.privateKey: '%env(string:SAML_SP_PRIVATE_KEY)%'
nbgrp_onelogin_saml:
use_proxy_vars: '%env(bool:SAML_BEHIND_PROXY)%'
onelogin_settings:
default:
# Basic settings

View File

@@ -20,6 +20,7 @@ twig:
avatar_helper: '@App\Services\UserSystem\UserAvatarHelper'
available_themes: '%partdb.available_themes%'
saml_enabled: '%partdb.saml.enabled%'
part_preview_generator: '@App\Services\Attachments\PartPreviewGenerator'
when@test:
twig:

View File

@@ -137,6 +137,8 @@ want to edit it on docker, you have to map the file to a volume.
* `SAML_ENABLED`: When this is set to 1, SAML SSO is enabled and the SSO Login button is shown in the login form. You
have to configure the SAML settings below, before you can use this feature.
* `SAML_BEHIND_PROXY`: Set this to 1, if Part-DB is behind a reverse proxy. See [here]({% link installation/reverse-proxy.md %})
for more information. Otherwise, leave it to 0 (default.)
* `SAML_ROLE_MAPPING`: A [JSON](https://en.wikipedia.org/wiki/JSON) encoded map which specifies how Part-DB should
convert the user roles given by SAML attribute `group` should be converted to a Part-DB group (specified by ID). You
can use a wildcard `*` to map all otherwise unmapped roles to a certain group.

View File

@@ -12,8 +12,8 @@ It is installed on a web server and so can be accessed with any browser without
{: .important-title }
> Demo
>
> If you want to test Part-DB without installing it, you can use [this](https://part-db.herokuapp.com) Heroku instance.
> (Or this link for the [German Version](https://part-db.herokuapp.com/de/)).
> If you want to test Part-DB without installing it, you can use [this](https://demo.part-db.de/) Heroku instance.
> (Or this link for the [German Version](https://demo.part-db.de/de/)).
>
> You can log in with username: **user** and password: **user**, to change/create data.
>

View File

@@ -150,6 +150,7 @@ services:
database:
container_name: partdb_database
image: mysql:8.0
restart: unless-stopped
command: --default-authentication-plugin=mysql_native_password
environment:
# Change this Password

View File

@@ -20,4 +20,21 @@ TRUSTED_PROXIES=192.168.2.10
```
Set the `DEFAULT_URI` environment variable to the URL of your Part-DB installation, available from the outside (so via
the reverse proxy).
the reverse proxy).
## Part-DB in a subpath via reverse proxy
If you put Part-DB into a subpath via the reverse proxy, you have to configure your webserver to include `X-Forwarded-Prefix` in the request headers.
For example if you put Part-DB behind a reverse proxy with the URL `https://example.com/partdb`, you have to set the `X-Forwarded-Prefix` header to `/partdb`.
In apache, you can do this by adding the following line to your virtual host configuration:
```
RequestHeader set X-Forwarded-Prefix "/partdb"
```
and in nginx, you can do this by adding the following line to your server configuration:
```
proxy_set_header X-Forwarded-Prefix "/partdb";
```

View File

@@ -230,3 +230,8 @@ Normally you don't have to change anything here.
Please note that this file is not saved by the Part-DB backup tool, so you have to save it manually if you want to keep
your changes. On docker containers you have to configure a volume mapping for it.
## SAML behind a reverse proxy
If you are running Part-DB behind a reverse proxy, configure the `TRUSTED_PROXIES` environment and other reverse proxy
settings as described in the [reverse proxy guide]({% link installation/reverse-proxy.md %}).
If you want to use SAML you also need to set `SAML_BEHIND_PROXY` to `true` to enable the SAML proxy mode.

View File

@@ -160,7 +160,7 @@ EOD;
21840,21840,21840,21840,21840,21520,21520,21520,20480,21520,20480,
20480,20480,20480,20480,21504,20480),
(
2,'admin', '${admin_pw}','','',
2,'admin', '$admin_pw','','',
'','',1,1,21845,21845,21845,21,85,21,349525,21845,21845,21845,21845
,21845,21845,21845,21845,21845,21845,21845,21845,21845,21845,21845,
21845,21845,21845,21845,21845,21845);

View File

@@ -234,8 +234,8 @@ final class Version20190902140506 extends AbstractMultiPlatformMigration
'orderdetails', 'pricedetails', 'storelocations', 'suppliers', ];
foreach ($tables as $table) {
$this->addSql("UPDATE ${table} SET datetime_added = NOW() WHERE datetime_added = '0000-00-00 00:00:00'");
$this->addSql("UPDATE ${table} SET last_modified = datetime_added WHERE last_modified = '0000-00-00 00:00:00'");
$this->addSql("UPDATE $table SET datetime_added = NOW() WHERE datetime_added = '0000-00-00 00:00:00'");
$this->addSql("UPDATE $table SET last_modified = datetime_added WHERE last_modified = '0000-00-00 00:00:00'");
}
//Set the dbVersion to a high value, to prevent the old Part-DB versions to upgrade DB!

View File

@@ -83,9 +83,12 @@ final class Version20221114193325 extends AbstractMultiPlatformMigration impleme
//Reset the permissions of the admin user, to allow admin permissions (like the admins group)
$this->addSql("UPDATE `users` SET permissions_data = '$admin' WHERE id = 2;");
//This warning should not be needed, anymore, as almost everybody should have updated to the new version by now, and this warning would just irritate new users of the software
/*
$this->logger->warning('<bg=cyan;fg=black>!!! All permissions were reset! Please change them to the desired state, immediately !!!</>');
$this->logger->warning('<bg=cyan;fg=black>!!! For security reasons all users (except the admin user) were disabled. Login with admin user and reenable other users after checking their permissions !!!</>');
$this->logger->warning('<bg=cyan;fg=black>!!! For more infos see: https://github.com/Part-DB/Part-DB-symfony/discussions/193 !!!</>');
*/
}
public function mySQLUp(Schema $schema): void

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20231114223101 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add schema for part associations and vendor barcodes';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('CREATE TABLE part_association (id INT AUTO_INCREMENT NOT NULL, owner_id INT NOT NULL, other_id INT NOT NULL, type SMALLINT NOT NULL, other_type VARCHAR(255) DEFAULT NULL, comment LONGTEXT DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, INDEX IDX_61B952E07E3C61F9 (owner_id), INDEX IDX_61B952E0998D9879 (other_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE part_association ADD CONSTRAINT FK_61B952E07E3C61F9 FOREIGN KEY (owner_id) REFERENCES `parts` (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE part_association ADD CONSTRAINT FK_61B952E0998D9879 FOREIGN KEY (other_id) REFERENCES `parts` (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE part_lots ADD vendor_barcode VARCHAR(255) DEFAULT NULL');
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE part_association DROP FOREIGN KEY FK_61B952E07E3C61F9');
$this->addSql('ALTER TABLE part_association DROP FOREIGN KEY FK_61B952E0998D9879');
$this->addSql('DROP TABLE part_association');
$this->addSql('DROP INDEX part_lots_idx_barcode ON part_lots');
$this->addSql('ALTER TABLE part_lots DROP vendor_barcode');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('CREATE TABLE part_association (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, owner_id INTEGER NOT NULL, other_id INTEGER NOT NULL, type SMALLINT NOT NULL, other_type VARCHAR(255) DEFAULT NULL, comment CLOB DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_61B952E07E3C61F9 FOREIGN KEY (owner_id) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_61B952E0998D9879 FOREIGN KEY (other_id) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_61B952E07E3C61F9 ON part_association (owner_id)');
$this->addSql('CREATE INDEX IDX_61B952E0998D9879 ON part_association (other_id)');
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added FROM part_lots');
$this->addSql('DROP TABLE part_lots');
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT 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, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES storelocations (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO part_lots (id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added) SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added FROM __temp__part_lots');
$this->addSql('DROP TABLE __temp__part_lots');
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
$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)');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('DROP TABLE part_association');
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added FROM part_lots');
$this->addSql('DROP TABLE part_lots');
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT 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, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT 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, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added) SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added 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)');
}
}

View File

@@ -26,7 +26,7 @@ parameters:
checkUninitializedProperties: true
checkFunctionNameCase: true
checkFunctionNameCase: false
checkAlwaysTrueInstanceof: false
checkAlwaysTrueCheckTypeFunctionCall: false

View File

@@ -26,6 +26,7 @@ namespace App\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\State\PartDBInfoProvider;
@@ -35,7 +36,7 @@ use App\State\PartDBInfoProvider;
#[ApiResource(
uriTemplate: '/info.{_format}',
description: 'Basic information about Part-DB like version, title, etc.',
operations: [new Get(openapiContext: ['summary' => 'Get basic information about the installed Part-DB instance.'])],
operations: [new Get(openapi: new Operation(summary: 'Get basic information about the installed Part-DB instance.'))],
provider: PartDBInfoProvider::class
)]
#[ApiFilter(PropertyFilter::class)]

View File

@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Parts\Part;
use App\Exceptions\AttachmentDownloadException;
use App\Form\InfoProviderSystem\PartSearchType;
use App\Form\Part\PartBaseType;
@@ -32,6 +33,7 @@ use App\Services\InfoProviderSystem\ProviderRegistry;
use App\Services\LogSystem\EventCommentHelper;
use App\Services\Parts\PartFormHelper;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -61,7 +63,8 @@ class InfoProviderController extends AbstractController
}
#[Route('/search', name: 'info_providers_search')]
public function search(Request $request): Response
#[Route('/update/{target}', name: 'info_providers_update_part_search')]
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target): Response
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
@@ -70,6 +73,12 @@ class InfoProviderController extends AbstractController
$results = null;
//When we are updating a part, use its name as keyword, to make searching easier
//However we can only do this, if the form was not submitted yet
if ($update_target !== null && !$form->isSubmitted()) {
$form->get('keyword')->setData($update_target->getName());
}
if ($form->isSubmitted() && $form->isValid()) {
$keyword = $form->get('keyword')->getData();
$providers = $form->get('providers')->getData();
@@ -80,6 +89,7 @@ class InfoProviderController extends AbstractController
return $this->render('info_providers/search/part_search.html.twig', [
'form' => $form,
'results' => $results,
'update_target' => $update_target
]);
}
}

View File

@@ -36,6 +36,7 @@ use App\Exceptions\AttachmentDownloadException;
use App\Form\Part\PartBaseType;
use App\Services\Attachments\AttachmentSubmitHandler;
use App\Services\Attachments\PartPreviewGenerator;
use App\Services\EntityMergers\Mergers\PartMerger;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\LogSystem\EventCommentHelper;
use App\Services\LogSystem\HistoryHelper;
@@ -233,6 +234,48 @@ class PartController extends AbstractController
]);
}
#[Route('/{target}/merge/{other}', name: 'part_merge')]
public function merge(Request $request, Part $target, Part $other, PartMerger $partMerger): Response
{
$this->denyAccessUnlessGranted('edit', $target);
$this->denyAccessUnlessGranted('delete', $other);
//Save the old name of the target part for the template
$target_name = $target->getName();
$this->addFlash('notice', t('part.merge.flash.please_review'));
$merged = $partMerger->merge($target, $other);
return $this->renderPartForm('merge', $request, $merged, [], [
'tname_before' => $target_name,
'other_part' => $other,
]);
}
#[Route(path: '/{id}/from_info_provider/{providerKey}/{providerId}/update', name: 'info_providers_update_part', requirements: ['providerId' => '.+'])]
public function updateFromInfoProvider(Part $part, Request $request, string $providerKey, string $providerId,
PartInfoRetriever $infoRetriever, PartMerger $partMerger): Response
{
$this->denyAccessUnlessGranted('edit', $part);
$this->denyAccessUnlessGranted('@info_providers.create_parts');
//Save the old name of the target part for the template
$old_name = $part->getName();
$dto = $infoRetriever->getDetails($providerKey, $providerId);
$provider_part = $infoRetriever->dtoToPart($dto);
$part = $partMerger->merge($part, $provider_part);
$this->addFlash('notice', t('part.merge.flash.please_review'));
return $this->renderPartForm('update_from_ip', $request, $part, [
'info_provider_dto' => $dto,
], [
'tname_before' => $old_name
]);
}
/**
* This function provides a common implementation for methods, which use the part form.
* @param Request $request
@@ -240,10 +283,10 @@ class PartController extends AbstractController
* @param array $form_options
* @return Response
*/
private function renderPartForm(string $mode, Request $request, Part $data, array $form_options = []): Response
private function renderPartForm(string $mode, Request $request, Part $data, array $form_options = [], array $merge_infos = []): Response
{
//Ensure that mode is either 'new' or 'edit
if (!in_array($mode, ['new', 'edit'], true)) {
if (!in_array($mode, ['new', 'edit', 'merge', 'update_from_ip'], true)) {
throw new \InvalidArgumentException('Invalid mode given');
}
@@ -276,6 +319,12 @@ class PartController extends AbstractController
$this->commentHelper->setMessage($form['log_comment']->getData());
$this->em->persist($new_part);
//When we are in merge mode, we have to remove the other part
if ($mode === 'merge') {
$this->em->remove($merge_infos['other_part']);
}
$this->em->flush();
if ($mode === 'new') {
$this->addFlash('success', 'part.created_flash');
@@ -310,12 +359,18 @@ class PartController extends AbstractController
$template = 'parts/edit/new_part.html.twig';
} else if ($mode === 'edit') {
$template = 'parts/edit/edit_part_info.html.twig';
} else if ($mode === 'merge') {
$template = 'parts/edit/merge_parts.html.twig';
} else if ($mode === 'update_from_ip') {
$template = 'parts/edit/update_from_ip.html.twig';
}
return $this->render($template,
[
'part' => $new_part,
'form' => $form,
'merge_old_name' => $merge_infos['tname_before'] ?? null,
'merge_other' => $merge_infos['other_part'] ?? null
]);
}
@@ -345,23 +400,35 @@ class PartController extends AbstractController
$amount = (float) $request->request->get('amount');
$comment = $request->request->get('comment');
$action = $request->request->get('action');
$delete_lot_if_empty = $request->request->getBoolean('delete_lot_if_empty', false);
$timestamp = null;
$timestamp_str = $request->request->getString('timestamp', '');
//Try to parse the timestamp
if($timestamp_str !== '') {
$timestamp = new DateTime($timestamp_str);
}
//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!");
}
try {
switch ($action) {
case "withdraw":
case "remove":
$this->denyAccessUnlessGranted('withdraw', $partLot);
$withdrawAddHelper->withdraw($partLot, $amount, $comment);
$withdrawAddHelper->withdraw($partLot, $amount, $comment, $timestamp, $delete_lot_if_empty);
break;
case "add":
$this->denyAccessUnlessGranted('add', $partLot);
$withdrawAddHelper->add($partLot, $amount, $comment);
$withdrawAddHelper->add($partLot, $amount, $comment, $timestamp);
break;
case "move":
$this->denyAccessUnlessGranted('move', $partLot);
$this->denyAccessUnlessGranted('move', $targetLot);
$withdrawAddHelper->move($partLot, $targetLot, $amount, $comment);
$withdrawAddHelper->move($partLot, $targetLot, $amount, $comment, $timestamp, $delete_lot_if_empty);
break;
default:
throw new \RuntimeException("Unknown action!");

View File

@@ -42,8 +42,10 @@ declare(strict_types=1);
namespace App\Controller;
use App\Form\LabelSystem\ScanDialogType;
use App\Services\LabelSystem\Barcodes\BarcodeNormalizer;
use App\Services\LabelSystem\Barcodes\BarcodeScanHelper;
use App\Services\LabelSystem\Barcodes\BarcodeRedirector;
use App\Services\LabelSystem\Barcodes\BarcodeScanResult;
use App\Services\LabelSystem\Barcodes\BarcodeSourceType;
use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -55,7 +57,7 @@ use Symfony\Component\Routing\Annotation\Route;
#[Route(path: '/scan')]
class ScanController extends AbstractController
{
public function __construct(protected BarcodeRedirector $barcodeParser, protected BarcodeNormalizer $barcodeNormalizer)
public function __construct(protected BarcodeRedirector $barcodeParser, protected BarcodeScanHelper $barcodeNormalizer)
{
}
@@ -69,14 +71,14 @@ class ScanController extends AbstractController
if ($input === null && $form->isSubmitted() && $form->isValid()) {
$input = $form['input']->getData();
$mode = $form['mode']->getData();
}
if ($input !== null) {
try {
[$type, $id] = $this->barcodeNormalizer->normalizeBarcodeContent($input);
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
try {
return $this->redirect($this->barcodeParser->getRedirectURL($type, $id));
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
} catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found');
}
@@ -95,10 +97,23 @@ class ScanController extends AbstractController
*/
public function scanQRCode(string $type, int $id): Response
{
$type = strtolower($type);
try {
$this->addFlash('success', 'scan.qr_success');
return $this->redirect($this->barcodeParser->getRedirectURL($type, $id));
if (!isset(BarcodeScanHelper::QR_TYPE_MAP[$type])) {
throw new InvalidArgumentException('Unknown type: '.$type);
}
//Construct the scan result manually, as we don't have a barcode here
$scan_result = new BarcodeScanResult(
target_type: BarcodeScanHelper::QR_TYPE_MAP[$type],
target_id: $id,
//The routes are only used on the internal generated QR codes
source_type: BarcodeSourceType::INTERNAL
);
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
} catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found');

View File

@@ -83,6 +83,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$part->setManufacturer($manager->find(Manufacturer::class, 1));
$part->setTags('test, Test, Part2');
$part->setMass(100.2);
$part->setIpn('IPN123');
$part->setNeedsReview(true);
$part->setManufacturingStatus(ManufacturingStatus::ACTIVE);
$manager->persist($part);
@@ -102,6 +103,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$partLot2->setComment('Test');
$partLot2->setNeedsRefill(true);
$partLot2->setStorageLocation($manager->find(StorageLocation::class, 3));
$partLot2->setVendorBarcode('lot2_vendor_barcode');
$part->addPartLot($partLot2);
$orderdetail = new Orderdetail();

View File

@@ -20,14 +20,17 @@ declare(strict_types=1);
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Helpers;
use App\Entity\Parts\StorageLocation;
use App\Entity\ProjectSystem\Project;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\PartPreviewGenerator;
use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
@@ -35,8 +38,13 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
class PartDataTableHelper
{
public function __construct(private readonly PartPreviewGenerator $previewGenerator, private readonly AttachmentURLGenerator $attachmentURLGenerator, private readonly EntityURLGenerator $entityURLGenerator, private readonly TranslatorInterface $translator)
{
public function __construct(
private readonly PartPreviewGenerator $previewGenerator,
private readonly AttachmentURLGenerator $attachmentURLGenerator,
private readonly EntityURLGenerator $entityURLGenerator,
private readonly TranslatorInterface $translator,
private readonly AmountFormatter $amountFormatter,
) {
}
public function renderName(Part $context): string
@@ -45,14 +53,16 @@ class PartDataTableHelper
//Depending on the part status we show a different icon (the later conditions have higher priority)
if ($context->isFavorite()) {
$icon = sprintf('<i class="fa-solid fa-star fa-fw me-1" title="%s"></i>', $this->translator->trans('part.favorite.badge'));
$icon = sprintf('<i class="fa-solid fa-star fa-fw me-1" title="%s"></i>',
$this->translator->trans('part.favorite.badge'));
}
if ($context->isNeedsReview()) {
$icon = sprintf('<i class="fa-solid fa-ambulance fa-fw me-1" title="%s"></i>', $this->translator->trans('part.needs_review.badge'));
$icon = sprintf('<i class="fa-solid fa-ambulance fa-fw me-1" title="%s"></i>',
$this->translator->trans('part.needs_review.badge'));
}
if ($context->getBuiltProject() instanceof Project) {
$icon = sprintf('<i class="fa-solid fa-box-archive fa-fw me-1" title="%s"></i>',
$this->translator->trans('part.info.projectBuildPart.hint') . ': ' . $context->getBuiltProject()->getName());
$this->translator->trans('part.info.projectBuildPart.hint').': '.$context->getBuiltProject()->getName());
}
@@ -85,4 +95,62 @@ class PartDataTableHelper
$title
);
}
public function renderStorageLocations(Part $context): string
{
$tmp = [];
foreach ($context->getPartLots() as $lot) {
//Ignore lots without storelocation
if (!$lot->getStorageLocation() instanceof StorageLocation) {
continue;
}
$tmp[] = sprintf(
'<a href="%s" title="%s">%s</a>',
$this->entityURLGenerator->listPartsURL($lot->getStorageLocation()),
htmlspecialchars($lot->getStorageLocation()->getFullPath()),
htmlspecialchars($lot->getStorageLocation()->getName())
);
}
return implode('<br>', $tmp);
}
public function renderAmount(Part $context): string
{
$amount = $context->getAmountSum();
$expiredAmount = $context->getExpiredAmountSum();
$ret = '';
if ($context->isAmountUnknown()) {
//When all amounts are unknown, we show a question mark
if ($amount === 0.0) {
$ret .= sprintf('<b class="text-primary" title="%s">?</b>',
$this->translator->trans('part_lots.instock_unknown'));
} else { //Otherwise mark it with greater equal and the (known) amount
$ret .= sprintf('<b class="text-primary" title="%s">≥</b>',
$this->translator->trans('part_lots.instock_unknown')
);
$ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
}
} else {
$ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
}
//If we have expired lots, we show them in parentheses behind
if ($expiredAmount > 0) {
$ret .= sprintf(' <span title="%s" class="text-muted">(+%s)</span>',
$this->translator->trans('part_lots.is_expired'),
htmlspecialchars($this->amountFormatter->format($expiredAmount, $context->getPartUnit())));
}
//When the amount is below the minimum amount, we highlight the number red
if ($context->isNotEnoughInstock()) {
$ret = sprintf('<b class="text-danger" title="%s">%s</b>',
$this->translator->trans('part.info.amount.less_than_desired'),
$ret);
}
return $ret;
}
}

View File

@@ -139,7 +139,7 @@ class LogDataTable implements DataTableTypeInterface
if ($context instanceof PartStockChangedLogEntry) {
$text .= sprintf(
' (<i>%s</i>)',
$this->translator->trans('log.part_stock_changed.' . $context->getInstockChangeType()->toExtraShortType())
$this->translator->trans($context->getInstockChangeType()->toTranslationKey())
);
}

View File

@@ -139,63 +139,11 @@ final class PartsDataTable implements DataTableTypeInterface
->add('storelocation', TextColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'),
'orderField' => 'storelocations.name',
'render' => function ($value, Part $context): string {
$tmp = [];
foreach ($context->getPartLots() as $lot) {
//Ignore lots without storelocation
if (!$lot->getStorageLocation() instanceof StorageLocation) {
continue;
}
$tmp[] = sprintf(
'<a href="%s" title="%s">%s</a>',
$this->urlGenerator->listPartsURL($lot->getStorageLocation()),
htmlspecialchars($lot->getStorageLocation()->getFullPath()),
htmlspecialchars($lot->getStorageLocation()->getName())
);
}
return implode('<br>', $tmp);
},
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
], alias: 'storage_location')
->add('amount', TextColumn::class, [
'label' => $this->translator->trans('part.table.amount'),
'render' => function ($value, Part $context) {
$amount = $context->getAmountSum();
$expiredAmount = $context->getExpiredAmountSum();
$ret = '';
if ($context->isAmountUnknown()) {
//When all amounts are unknown, we show a question mark
if ($amount === 0.0) {
$ret .= sprintf('<b class="text-primary" title="%s">?</b>',
$this->translator->trans('part_lots.instock_unknown'));
} else { //Otherwise mark it with greater equal and the (known) amount
$ret .= sprintf('<b class="text-primary" title="%s">≥</b>',
$this->translator->trans('part_lots.instock_unknown')
);
$ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
}
} else {
$ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
}
//If we have expired lots, we show them in parentheses behind
if ($expiredAmount > 0) {
$ret .= sprintf(' <span title="%s" class="text-muted">(+%s)</span>',
$this->translator->trans('part_lots.is_expired'),
htmlspecialchars($this->amountFormatter->format($expiredAmount, $context->getPartUnit())));
}
//When the amount is below the minimum amount, we highlight the number red
if ($context->isNotEnoughInstock()) {
$ret = sprintf('<b class="text-danger" title="%s">%s</b>',
$this->translator->trans('part.info.amount.less_than_desired'),
$ret);
}
return $ret;
},
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
'orderField' => 'amountSum'
])
->add('minamount', TextColumn::class, [

View File

@@ -100,7 +100,16 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
throw new \Exception('This should never happen!');
},
])
->add('ipn', TextColumn::class, [
'label' => $this->translator->trans('part.table.ipn'),
'orderField' => 'part.ipn',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
if($context->getPart() instanceof Part) {
return $context->getPart()->getIpn();
}
}
])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('part.table.description'),
'data' => function (ProjectBOMEntry $context) {
@@ -142,6 +151,28 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
},
])
->add('instockAmount', TextColumn::class, [
'label' => 'project.bom.instockAmount',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
if ($context->getPart()) {
return $this->partDataTableHelper->renderAmount($context->getPart());
}
return '';
}
])
->add('storageLocations', TextColumn::class, [
'label' => 'part.table.storeLocations',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
if ($context->getPart()) {
return $this->partDataTableHelper->renderStorageLocations($context->getPart());
}
return '';
}
])
->add('addedDate', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.addedDate'),

View File

@@ -41,6 +41,20 @@ class TinyIntType extends Type
return 'tinyint';
}
/**
* {@inheritDoc}
*
* @param T $value
*
* @return (T is null ? null : int)
*
* @template T
*/
public function convertToPHPValue($value, AbstractPlatform $platform)
{
return $value === null ? null : (int) $value;
}
public function requiresSQLCommentHint(AbstractPlatform $platform): bool
{
//We use the comment, so that doctrine migrations can properly detect, that nothing has changed and no migration is needed.

View File

@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Parts\Footprint;
@@ -70,7 +71,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
uriTemplate: '/attachment_types/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of an attachment type.'],
new GetCollection(openapi: new Operation(summary: 'Retrieves the children elements of an attachment type.'),
security: 'is_granted("@attachment_types.read")')
],
uriVariables: [

View File

@@ -29,6 +29,7 @@ use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartAssociation;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
@@ -63,6 +64,8 @@ enum LogTargetType: int
case PARAMETER = 18;
case LABEL_PROFILE = 19;
case PART_ASSOCIATION = 20;
/**
* Returns the class name of the target type or null if the target type is NONE.
* @return string|null
@@ -90,6 +93,7 @@ enum LogTargetType: int
self::MEASUREMENT_UNIT => MeasurementUnit::class,
self::PARAMETER => AbstractParameter::class,
self::LABEL_PROFILE => LabelProfile::class,
self::PART_ASSOCIATION => PartAssociation::class,
};
}

View File

@@ -39,6 +39,11 @@ enum PartStockChangeType: string
};
}
public function toTranslationKey(): string
{
return 'log.part_stock_changed.' . $this->value;
}
public static function fromExtraShortType(string $value): self
{
return match ($value) {

View File

@@ -41,8 +41,10 @@ class PartStockChangedLogEntry extends AbstractLogEntry
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
* @param PartLot|null $move_to_target The target lot if the type is TYPE_MOVE.
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
*/
protected function __construct(PartStockChangeType $type, PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?PartLot $move_to_target = null)
protected function __construct(PartStockChangeType $type, PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?PartLot $move_to_target = null,
?\DateTimeInterface $action_timestamp = null)
{
parent::__construct();
@@ -62,6 +64,11 @@ class PartStockChangedLogEntry extends AbstractLogEntry
$this->extra['c'] = mb_strimwidth($comment, 0, self::COMMENT_MAX_LENGTH, '...');
}
if ($action_timestamp instanceof \DateTimeInterface) {
//The action timestamp is saved as an ISO 8601 string
$this->extra['a'] = $action_timestamp->format(\DateTimeInterface::ATOM);
}
if ($move_to_target instanceof PartLot) {
if ($type !== PartStockChangeType::MOVE) {
throw new \InvalidArgumentException('The move_to_target parameter can only be set if the type is "move"!');
@@ -78,11 +85,12 @@ class PartStockChangedLogEntry extends AbstractLogEntry
* @param float $new_stock The new stock of the lot.
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
* @return self
*/
public static function add(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment): self
public static function add(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self
{
return new self(PartStockChangeType::ADD, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment);
return new self(PartStockChangeType::ADD, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp);
}
/**
@@ -92,11 +100,12 @@ class PartStockChangedLogEntry extends AbstractLogEntry
* @param float $new_stock The new stock of the lot.
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
* @return self
*/
public static function withdraw(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment): self
public static function withdraw(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self
{
return new self(PartStockChangeType::WITHDRAW, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment);
return new self(PartStockChangeType::WITHDRAW, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp);
}
/**
@@ -107,10 +116,12 @@ class PartStockChangedLogEntry extends AbstractLogEntry
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
* @param PartLot $move_to_target The target lot.
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
* @return self
*/
public static function move(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, PartLot $move_to_target): self
public static function move(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, PartLot $move_to_target, ?\DateTimeInterface $action_timestamp = null): self
{
return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target);
return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target, action_timestamp: $action_timestamp);
}
/**
@@ -169,4 +180,18 @@ class PartStockChangedLogEntry extends AbstractLogEntry
{
return $this->extra['m'] ?? null;
}
/**
* Returns the timestamp when this action was performed and not when the log entry was created.
* This is useful if the action happened in the past, and the log entry is created afterwards.
* If the timestamp is not set, null is returned.
* @return \DateTimeInterface|null
*/
public function getActionTimestamp(): ?\DateTimeInterface
{
if (!empty($this->extra['a'])) {
return \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $this->extra['a']);
}
return null;
}
}

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\Parts;
/**
* The values of this enums are used to describe how two parts are associated with each other.
*/
enum AssociationType: int
{
/** A user definable association type, which can be described in the comment field */
case OTHER = 0;
/** The owning part is compatible with the other part */
case COMPATIBLE = 1;
/** The owning part supersedes the other part (owner is newer version) */
case SUPERSEDES = 2;
/**
* Returns the translation key for this association type.
* @return string
*/
public function getTranslationKey(): string
{
return 'part_association.type.' . strtolower($this->name);
}
}

View File

@@ -34,6 +34,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
@@ -72,8 +73,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
uriTemplate: '/categories/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a category.'],
security: 'is_granted("@categories.read")')
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a category.'),
security: 'is_granted("@categories.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Category::class)

View File

@@ -34,6 +34,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
@@ -72,8 +73,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
uriTemplate: '/footprints/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a footprint.'],
security: 'is_granted("@footprints.read")')
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a footprint.'),
security: 'is_granted("@footprints.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Footprint::class)

View File

@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
@@ -71,8 +72,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
uriTemplate: '/manufacturers/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a manufacturer.'],
security: 'is_granted("@manufacturers.read")')
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a manufacturer.'),
security: 'is_granted("@manufacturers.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)

View File

@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
@@ -75,8 +76,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
uriTemplate: '/footprints/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a MeasurementUnit.'],
security: 'is_granted("@measurement_units.read")')
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a MeasurementUnit.'),
security: 'is_granted("@measurement_units.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: MeasurementUnit::class)

View File

@@ -41,6 +41,7 @@ use App\ApiPlatform\Filter\EntityFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\ApiPlatform\Filter\PartStoragelocationFilter;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Entity\Parts\PartTraits\AssociationTrait;
use App\Repository\PartRepository;
use Doctrine\DBAL\Types\Types;
use App\Entity\Attachments\Attachment;
@@ -58,6 +59,7 @@ use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Jfcherng\Diff\Utility\Arr;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
@@ -112,6 +114,7 @@ class Part extends AttachmentContainingDBElement
use OrderTrait;
use ParametersTrait;
use ProjectTrait;
use AssociationTrait;
/** @var Collection<int, PartParameter>
*/
@@ -165,6 +168,9 @@ class Part extends AttachmentContainingDBElement
$this->parameters = new ArrayCollection();
$this->project_bom_entries = new ArrayCollection();
$this->associated_parts_as_owner = new ArrayCollection();
$this->associated_parts_as_other = new ArrayCollection();
//By default, the part has no provider
$this->providerReference = InfoProviderReference::noProvider();
}
@@ -193,6 +199,13 @@ class Part extends AttachmentContainingDBElement
$this->addParameter(clone $parameter);
}
//Deep clone the owned part associations (the owned ones make not much sense without the owner)
$ownedAssociations = $this->associated_parts_as_owner;
$this->associated_parts_as_owner = new ArrayCollection();
foreach ($ownedAssociations as $association) {
$this->addAssociatedPartsAsOwner(clone $association);
}
//Deep clone info provider
$this->providerReference = clone $this->providerReference;
}

View File

@@ -0,0 +1,233 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\Parts;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Repository\DBElementRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* This entity describes a part association, which is a semantic connection between two parts.
* For example, a part association can be used to describe that a part is a replacement for another part.
*/
#[ORM\Entity(repositoryClass: DBElementRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['other', 'owner', 'type'], message: 'validator.part_association.already_exists')]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@parts.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['part_assoc:read', 'part_assoc:read:standalone', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['part_assoc:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["other_type", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['comment', 'addedDate', 'lastModified'])]
class PartAssociation extends AbstractDBElement
{
use TimestampTrait;
/**
* @var AssociationType The type of this association (how the two parts are related)
*/
#[ORM\Column(type: Types::SMALLINT, enumType: AssociationType::class)]
#[Groups(['part_assoc:read', 'part_assoc:write'])]
protected AssociationType $type = AssociationType::OTHER;
/**
* @var string|null A user definable association type, which can be described in the comment field, which
* is used if the type is OTHER
*/
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Assert\Expression("this.getType().value !== 0 or this.getOtherType() !== null",
message: 'validator.part_association.must_set_an_value_if_type_is_other')]
#[Groups(['part_assoc:read', 'part_assoc:write'])]
protected ?string $other_type = null;
/**
* @var string|null A comment describing this association further.
*/
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['part_assoc:read', 'part_assoc:write'])]
protected ?string $comment = null;
/**
* @var Part|null The part which "owns" this association, e.g. the part which is a replacement for another part
*/
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'associated_parts_as_owner')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
#[Groups(['part_assoc:read:standalone', 'part_assoc:write'])]
protected ?Part $owner = null;
/**
* @var Part|null The part which is "owned" by this association, e.g. the part which is replaced by another part
*/
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'associated_parts_as_other')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
#[Assert\Expression("this.getOwner() !== this.getOther()",
message: 'validator.part_association.part_cannot_be_associated_with_itself')]
#[Groups(['part_assoc:read', 'part_assoc:write'])]
protected ?Part $other = null;
/**
* Returns the (semantic) relation type of this association as an AssociationType enum value.
* If the type is set to OTHER, then the other_type field value is used for the user defined type.
* @return AssociationType
*/
public function getType(): AssociationType
{
return $this->type;
}
/**
* Sets the (semantic) relation type of this association as an AssociationType enum value.
* @param AssociationType $type
* @return $this
*/
public function setType(AssociationType $type): PartAssociation
{
$this->type = $type;
return $this;
}
/**
* Returns a comment, which describes this association further.
* @return string|null
*/
public function getComment(): ?string
{
return $this->comment;
}
/**
* Sets a comment, which describes this association further.
* @param string|null $comment
* @return $this
*/
public function setComment(?string $comment): PartAssociation
{
$this->comment = $comment;
return $this;
}
/**
* Returns the part which "owns" this association, e.g. the part which is a replacement for another part.
* @return Part|null
*/
public function getOwner(): ?Part
{
return $this->owner;
}
/**
* Sets the part which "owns" this association, e.g. the part which is a replacement for another part.
* @param Part|null $owner
* @return $this
*/
public function setOwner(?Part $owner): PartAssociation
{
$this->owner = $owner;
return $this;
}
/**
* Returns the part which is "owned" by this association, e.g. the part which is replaced by another part.
* @return Part|null
*/
public function getOther(): ?Part
{
return $this->other;
}
/**
* Sets the part which is "owned" by this association, e.g. the part which is replaced by another part.
* @param Part|null $other
* @return $this
*/
public function setOther(?Part $other): PartAssociation
{
$this->other = $other;
return $this;
}
/**
* Returns the user defined association type, which is used if the type is set to OTHER.
* @return string|null
*/
public function getOtherType(): ?string
{
return $this->other_type;
}
/**
* Sets the user defined association type, which is used if the type is set to OTHER.
* @param string|null $other_type
* @return $this
*/
public function setOtherType(?string $other_type): PartAssociation
{
$this->other_type = $other_type;
return $this;
}
/**
* Returns the translation key for the type of this association.
* If the type is set to OTHER, then the other_type field value is used.
* @return string
*/
public function getTypeTranslationKey(): string
{
if ($this->type === AssociationType::OTHER) {
return $this->other_type ?? 'Unknown';
}
return $this->type->getTranslationKey();
}
}

View File

@@ -27,6 +27,7 @@ use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
@@ -47,6 +48,7 @@ use App\Validator\Constraints\ValidPartLot;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -60,9 +62,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'part_lots')]
#[ORM\Index(name: 'part_lots_idx_instock_un_expiration_id_part', columns: ['instock_unknown', 'expiration_date', 'id_part'])]
#[ORM\Index(name: 'part_lots_idx_needs_refill', columns: ['needs_refill'])]
#[ORM\Index(columns: ['instock_unknown', 'expiration_date', 'id_part'], name: 'part_lots_idx_instock_un_expiration_id_part')]
#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')]
#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')]
#[ValidPartLot]
#[UniqueEntity(['vendor_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
@@ -144,6 +148,7 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'partLots')]
#[ORM\JoinColumn(name: 'id_part', nullable: false, onDelete: 'CASCADE')]
#[Groups(['part_lot:read:standalone', 'part_lot:write'])]
#[ApiProperty(writableLink: false)]
protected ?Part $part = null;
/**
@@ -152,8 +157,16 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'id_owner', onDelete: 'SET NULL')]
#[Groups(['part_lot:read', 'part_lot:write'])]
#[ApiProperty(writableLink: false)]
protected ?User $owner = null;
/**
* @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor)
*/
#[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['part_lot:read', 'part_lot:write'])]
protected ?string $vendor_barcode = null;
public function __clone()
{
if ($this->id) {
@@ -354,6 +367,29 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
return $this->description;
}
/**
* The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor), or
* null if no barcode is set.
* @return string|null
*/
public function getVendorBarcode(): ?string
{
return $this->vendor_barcode;
}
/**
* Set the content of the barcode of this part lot (e.g. a barcode on the package put by the vendor).
* @param string|null $vendor_barcode
* @return $this
*/
public function setVendorBarcode(?string $vendor_barcode): PartLot
{
$this->vendor_barcode = $vendor_barcode;
return $this;
}
#[Assert\Callback]
public function validate(ExecutionContextInterface $context, $payload): void
{

View File

@@ -0,0 +1,111 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\Parts\PartTraits;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartAssociation;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints\Valid;
use Doctrine\ORM\Mapping as ORM;
trait AssociationTrait
{
/**
* @var Collection<PartAssociation> All associations where this part is the owner
*/
#[Valid]
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: PartAssociation::class,
cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['part:read', 'part:write'])]
protected Collection $associated_parts_as_owner;
/**
* @var Collection<PartAssociation> All associations where this part is the owned/other part
*/
#[Valid]
#[ORM\OneToMany(mappedBy: 'other', targetEntity: PartAssociation::class,
cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['part:read'])]
protected Collection $associated_parts_as_other;
/**
* Returns all associations where this part is the owner.
* @return Collection<PartAssociation>
*/
public function getAssociatedPartsAsOwner(): Collection
{
return $this->associated_parts_as_owner;
}
/**
* Add a new association where this part is the owner.
* @param PartAssociation $association
* @return $this
*/
public function addAssociatedPartsAsOwner(PartAssociation $association): self
{
//Ensure that the association is really owned by this part
$association->setOwner($this);
$this->associated_parts_as_owner->add($association);
return $this;
}
/**
* Remove an association where this part is the owner.
* @param PartAssociation $association
* @return $this
*/
public function removeAssociatedPartsAsOwner(PartAssociation $association): self
{
$this->associated_parts_as_owner->removeElement($association);
return $this;
}
/**
* Returns all associations where this part is the owned/other part.
* If you want to modify the association, do it on the owning part
* @return Collection<PartAssociation>
*/
public function getAssociatedPartsAsOther(): Collection
{
return $this->associated_parts_as_other;
}
/**
* Returns all associations where this part is the owned or other part.
* @return Collection<PartAssociation>
*/
public function getAssociatedPartsAll(): Collection
{
return new ArrayCollection(
array_merge(
$this->associated_parts_as_owner->toArray(),
$this->associated_parts_as_other->toArray()
)
);
}
}

View File

@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
@@ -71,8 +72,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
uriTemplate: '/storage_locations/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a storage location.'],
security: 'is_granted("@storelocations.read")')
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a storage location.'),
security: 'is_granted("@storelocations.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)

View File

@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
@@ -75,8 +76,10 @@ use Symfony\Component\Validator\Constraints as Assert;
)]
#[ApiResource(
uriTemplate: '/suppliers/{id}/children.{_format}',
operations: [new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a supplier'],
security: 'is_granted("@manufacturers.read")')],
operations: [new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a supplier.'),
security: 'is_granted("@manufacturers.read")'
)],
uriVariables: [
'id' => new Link(fromClass: Supplier::class, fromProperty: 'children')
],

View File

@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
@@ -75,8 +76,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
uriTemplate: '/currencies/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a currency.'],
security: 'is_granted("@currencies.read")')
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a currency.'),
security: 'is_granted("@currencies.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Currency::class)

View File

@@ -34,6 +34,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use Doctrine\DBAL\Types\Types;
@@ -73,8 +74,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
uriTemplate: '/parts/{id}/orderdetails.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the orderdetails of a part.'],
security: 'is_granted("@parts.read")')
new GetCollection(
openapi: new Operation(summary: 'Retrieves the orderdetails of a part.'),
security: 'is_granted("@parts.read")'
)
],
uriVariables: [
'id' => new Link(toProperty: 'part', fromClass: Part::class)

View File

@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
@@ -74,8 +75,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiResource(
uriTemplate: '/projects/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a project.'],
security: 'is_granted("@projects.read")')
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a project.'),
security: 'is_granted("@projects.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Project::class)
@@ -183,7 +186,7 @@ class Project extends AbstractStructuralDBElement
//Set master attachment is needed
foreach ($bom_entries as $bom_entry) {
$clone = clone $bom_entry;
$this->bom_entries->add($clone);
$this->addBomEntry($clone);
}
}

View File

@@ -32,6 +32,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Validator\UniqueValidatableInterface;
@@ -69,8 +70,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiResource(
uriTemplate: '/projects/{id}/bom.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the BOM entries of the given project.'],
security: 'is_granted("@projects.read")')
new GetCollection(
openapi: new Operation(summary: 'Retrieves the BOM entries of the given project.'),
security: 'is_granted("@projects.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'bom_entries', fromClass: Project::class)

View File

@@ -26,6 +26,7 @@ namespace App\Entity\UserSystem;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\TimestampTrait;
@@ -46,7 +47,9 @@ use Symfony\Component\Validator\Constraints\NotBlank;
#[ApiResource(
uriTemplate: '/tokens/current.{_format}',
description: 'A token used to authenticate API requests.',
operations: [new Get(openapiContext: ['summary' => 'Get information about the API token that is currently used.'])],
operations: [new Get(
openapi: new Operation(summary: 'Get information about the API token that is currently used.'),
)],
normalizationContext: ['groups' => ['token:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
provider: CurrentApiTokenProvider::class,
)]

View File

@@ -31,6 +31,7 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
@@ -86,10 +87,14 @@ use Jbtronics\TFAWebauthn\Model\TwoFactorInterface as WebauthnTwoFactorInterface
#[ApiResource(
shortName: 'User',
operations: [
new Get(openapiContext: ['summary' => 'Get a specific user.'],
security: 'is_granted("read", object)'),
new GetCollection(openapiContext: ['summary' => 'Get all users defined in the system.'],
security: 'is_granted("@users.read")'),
new Get(
openapi: new Operation(summary: 'Get information about the current user.'),
security: 'is_granted("read", object)'
),
new GetCollection(
openapi: new Operation(summary: 'Get all users defined in the system.'),
security: 'is_granted("@users.read")'
),
],
normalizationContext: ['groups' => ['user:read'], 'openapi_definition_name' => 'Read'],
)]

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\WebpackEncoreBundle\Event\RenderAssetTagEvent;
/**
* This class fixes the wrong pathes generated by webpack using the auto publicPath mode.
* Basically it replaces the wrong /auto/ part of the path with the correct /build/ in all encore entrypoints.
*/
class WebpackAutoPathSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
RenderAssetTagEvent::class => 'onRenderAssetTag'
];
}
public function onRenderAssetTag(RenderAssetTagEvent $event): void
{
if ($event->isScriptTag()) {
$event->setAttribute('src', $this->resolveAuto($event->getUrl()));
}
if ($event->isLinkTag()) {
$event->setAttribute('href', $this->resolveAuto($event->getUrl()));
}
}
private function resolveAuto(string $path): string
{
//Replace the first occurence of /auto/ with /build/ to get the correct path
return preg_replace('/\/auto\//', '/build/', $path, 1);
}
}

View File

@@ -148,6 +148,7 @@ class LogFilterType extends AbstractType
LogTargetType::MEASUREMENT_UNIT => 'measurement_unit.label',
LogTargetType::PARAMETER => 'parameter.label',
LogTargetType::LABEL_PROFILE => 'label_profile.label',
LogTargetType::PART_ASSOCIATION => 'part_association.label',
},
]);

View File

@@ -41,7 +41,9 @@ declare(strict_types=1);
namespace App\Form\LabelSystem;
use App\Services\LabelSystem\Barcodes\BarcodeSourceType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -59,6 +61,21 @@ class ScanDialogType extends AbstractType
],
]);
$builder->add('mode', EnumType::class, [
'label' => 'scan_dialog.mode',
'expanded' => true,
'class' => BarcodeSourceType::class,
'required' => false,
'placeholder' => 'scan_dialog.mode.auto',
'choice_label' => fn (?BarcodeSourceType $enum) => match($enum) {
null => 'scan_dialog.mode.auto',
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
BarcodeSourceType::VENDOR => 'scan_dialog.mode.vendor',
},
]);
$builder->add('submit', SubmitType::class, [
'label' => 'scan_dialog.submit',
]);

View File

@@ -0,0 +1,73 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\Part;
use App\Entity\Parts\AssociationType;
use App\Entity\Parts\PartAssociation;
use App\Form\Type\PartSelectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PartAssociationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('other', PartSelectType::class, [
'label' => 'part_association.edit.other_part',
])
->add('type', EnumType::class, [
'class' => AssociationType::class,
'label' => 'part_association.edit.type',
'choice_label' => fn(AssociationType $type) => $type->getTranslationKey(),
'help' => 'part_association.edit.type.help',
'attr' => [
'data-pages--association-edit-type-select-target' => 'select'
]
])
->add('other_type', TextType::class, [
'required' => false,
'label' => 'part_association.edit.other_type',
'row_attr' => [
'data-pages--association-edit-type-select-target' => 'display'
]
])
->add('comment', TextType::class, [
'required' => false,
'label' => 'part_association.edit.comment'
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => PartAssociation::class,
]);
}
}

View File

@@ -245,6 +245,16 @@ class PartBaseType extends AbstractType
],
]);
//Part associations
$builder->add('associated_parts_as_owner', CollectionType::class, [
'entry_type' => PartAssociationType::class,
'allow_add' => true,
'allow_delete' => true,
'reindex_enable' => true,
'label' => false,
'by_reference' => false,
]);
$builder->add('log_comment', TextType::class, [
'label' => 'edit.log_comment',
'mapped' => false,

View File

@@ -80,7 +80,7 @@ class PartLotType extends AbstractType
'required' => false,
]);
$builder->add('expirationDate', DateType::class, [
$builder->add('expiration_date', DateType::class, [
'label' => 'part_lot.edit.expiration_date',
'attr' => [],
'widget' => 'single_text',
@@ -102,6 +102,12 @@ class PartLotType extends AbstractType
'required' => false,
'help' => 'part_lot.owner.help',
]);
$builder->add('vendor_barcode', TextType::class, [
'label' => 'part_lot.edit.vendor_barcode',
'help' => 'part_lot.edit.vendor_barcode.help',
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -45,6 +45,9 @@ class PermissionsType extends AbstractType
$resolver->setDefaults([
'show_legend' => true,
'show_presets' => false,
'show_dependency_notice' => static function (Options $options) {
return !$options['disabled'];
},
'constraints' => static function (Options $options) {
if (!$options['disabled']) {
return [new NoLockout()];
@@ -60,6 +63,7 @@ class PermissionsType extends AbstractType
{
$view->vars['show_legend'] = $options['show_legend'];
$view->vars['show_presets'] = $options['show_presets'];
$view->vars['show_dependency_notice'] = $options['show_dependency_notice'];
}
public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -0,0 +1,104 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Security\Voter;
use App\Entity\Parts\PartAssociation;
use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* This voter handles permissions for part associations.
* The permissions are inherited from the part.
*/
final class PartAssociationVoter extends Voter
{
public function __construct(private readonly Security $security, private readonly VoterHelper $helper)
{
}
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
if (!is_string($subject) && !$subject instanceof PartAssociation) {
throw new \RuntimeException('Invalid subject type!');
}
$operation = match ($attribute) {
'read' => 'read',
'edit', 'create', 'delete' => 'edit',
'show_history' => 'show_history',
'revert_element' => 'revert_element',
default => throw new \RuntimeException('Encountered unknown operation "'.$attribute.'"!'),
};
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getOwner() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
}
//Otherwise vote on the part
return $this->security->isGranted($attribute, $subject->getOwner());
}
protected function supports($attribute, $subject): bool
{
if (is_a($subject, PartAssociation::class, true)) {
return in_array($attribute, self::ALLOWED_PERMS, true);
}
return false;
}
public function supportsType(string $subjectType): bool
{
return $subjectType === 'string' || is_a($subjectType, PartAssociation::class, true);
}
public function supportsAttribute(string $attribute): bool
{
return in_array($attribute, self::ALLOWED_PERMS, true);
}
}

View File

@@ -28,6 +28,7 @@ use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Parts\PartAssociation;
use App\Entity\ProjectSystem\Project;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter;
@@ -80,6 +81,7 @@ class ElementTypeNameGenerator
User::class => $this->translator->trans('user.label'),
AbstractParameter::class => $this->translator->trans('parameter.label'),
LabelProfile::class => $this->translator->trans('label_profile.label'),
PartAssociation::class => $this->translator->trans('part_association.label'),
];
}

View File

@@ -0,0 +1,76 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\EntityMergers;
use App\Services\EntityMergers\Mergers\EntityMergerInterface;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
/**
* This service is used to merge two entities together.
* It automatically finds the correct merger (implementing EntityMergerInterface) for the two entities if one exists.
*/
class EntityMerger
{
public function __construct(#[TaggedIterator('app.entity_merger')] protected iterable $mergers)
{
}
/**
* This function finds the first merger that supports merging the other entity into the target entity.
* @param object $target
* @param object $other
* @param array $context
* @return EntityMergerInterface|null
*/
public function findMergerForObject(object $target, object $other, array $context = []): ?EntityMergerInterface
{
foreach ($this->mergers as $merger) {
if ($merger->supports($target, $other, $context)) {
return $merger;
}
}
return null;
}
/**
* This function merges the other entity into the target entity. If no merger is found an exception is thrown.
* The target entity will be modified and returned.
* @param object $target
* @param object $other
* @param array $context
* @template T of object
* @phpstan-param T $target
* @phpstan-param T $other
* @phpstan-return T
* @return object
*/
public function merge(object $target, object $other, array $context = []): object
{
$merger = $this->findMergerForObject($target, $other, $context);
if ($merger === null) {
throw new \RuntimeException('No merger found for merging '.get_class($other).' into '.get_class($target));
}
return $merger->merge($target, $other, $context);
}
}

View File

@@ -0,0 +1,374 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\EntityMergers\Mergers;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Part;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Contracts\Service\Attribute\Required;
use function Symfony\Component\String\u;
/**
* This trait provides helper methods for entity mergers.
* By default, it uses the value from the target entity, unless it not fullfills a condition.
*/
trait EntityMergerHelperTrait
{
protected PropertyAccessorInterface $property_accessor;
#[Required]
public function setPropertyAccessor(PropertyAccessorInterface $property_accessor): void
{
$this->property_accessor = $property_accessor;
}
/**
* Choice the value to use from the target or the other entity by using a callback function.
*
* @param callable $callback The callback to use. The signature is: function($target_value, $other_value, $target, $other, $field). The callback should return the value to use.
* @param object $target The target entity
* @param object $other The other entity
* @param string $field The field to use
* @return object The target entity with the value set
*/
protected function useCallback(callable $callback, object $target, object $other, string $field): object
{
//Get the values from the entities
$target_value = $this->property_accessor->getValue($target, $field);
$other_value = $this->property_accessor->getValue($other, $field);
//Call the callback, with the signature: function($target_value, $other_value, $target, $other, $field)
//The callback should return the value to use
$value = $callback($target_value, $other_value, $target, $other, $field);
//Set the value
$this->property_accessor->setValue($target, $field, $value);
return $target;
}
/**
* Use the value from the other entity, if the value from the target entity is null.
*
* @param object $target The target entity
* @param object $other The other entity
* @param string $field The field to use
* @return object The target entity with the value set
*/
protected function useOtherValueIfNotNull(object $target, object $other, string $field): object
{
return $this->useCallback(
function ($target_value, $other_value) {
return $target_value ?? $other_value;
},
$target,
$other,
$field
);
}
/**
* Use the value from the other entity, if the value from the target entity is empty.
*
* @param object $target The target entity
* @param object $other The other entity
* @param string $field The field to use
* @return object The target entity with the value set
*/
protected function useOtherValueIfNotEmtpy(object $target, object $other, string $field): object
{
return $this->useCallback(
function ($target_value, $other_value) {
return empty($target_value) ? $other_value : $target_value;
},
$target,
$other,
$field
);
}
/**
* Use the larger value from the target and the other entity for the given field.
*
* @param object $target
* @param object $other
* @param string $field
* @return object
*/
protected function useLargerValue(object $target, object $other, string $field): object
{
return $this->useCallback(
function ($target_value, $other_value) {
return max($target_value, $other_value);
},
$target,
$other,
$field
);
}
/**
* Use the smaller value from the target and the other entity for the given field.
*
* @param object $target
* @param object $other
* @param string $field
* @return object
*/
protected function useSmallerValue(object $target, object $other, string $field): object
{
return $this->useCallback(
function ($target_value, $other_value) {
return min($target_value, $other_value);
},
$target,
$other,
$field
);
}
/**
* Perform an OR operation on the boolean values from the target and the other entity for the given field.
* This effectively means that the value is true, if it is true in at least one of the entities.
* @param object $target
* @param object $other
* @param string $field
* @return object
*/
protected function useTrueValue(object $target, object $other, string $field): object
{
return $this->useCallback(
function (bool $target_value, bool $other_value): bool {
return $target_value || $other_value;
},
$target,
$other,
$field
);
}
/**
* Perform a merge of comma separated lists from the target and the other entity for the given field.
* The values are merged and duplicates are removed.
* @param object $target
* @param object $other
* @param string $field
* @return object
*/
protected function mergeTags(object $target, object $other, string $field, string $separator = ','): object
{
return $this->useCallback(
function (string|null $t, string|null $o) use ($separator): string {
//Explode the strings into arrays
$t_array = explode($separator, $t ?? '');
$o_array = explode($separator, $o ?? '');
//Merge the arrays and remove duplicates
$tmp = array_unique(array_merge($t_array, $o_array));
//Implode the array back to a string
return implode($separator, $tmp);
},
$target,
$other,
$field
);
}
/**
* Merge the collections from the target and the other entity for the given field and put all items into the target collection.
* @param object $target
* @param object $other
* @param string $field
* @param callable|null $equal_fn A function, which checks if two items are equal. The signature is: function(object $target, object other): bool.
* Return true if the items are equal, false otherwise. If two items are equal, the item from the other collection is not added to the target collection.
* If null, the items are compared by (instance) identity.
* @return object
*/
protected function mergeCollections(object $target, object $other, string $field, ?callable $equal_fn = null): object
{
$target_collection = $this->property_accessor->getValue($target, $field);
$other_collection = $this->property_accessor->getValue($other, $field);
if (!$target_collection instanceof Collection) {
throw new \InvalidArgumentException("The target field $field is not a collection");
}
//Clone the items from the other collection
$clones = [];
foreach ($other_collection as $item) {
//Check if the item is already in the target collection
if ($equal_fn !== null) {
foreach ($target_collection as $target_item) {
if ($equal_fn($target_item, $item)) {
continue 2;
}
}
} else {
if ($target_collection->contains($item)) {
continue;
}
}
$clones[] = clone $item;
}
$tmp = array_merge($target_collection->toArray(), $clones);
//Create a new collection with the clones and merge it into the target collection
$this->property_accessor->setValue($target, $field, $tmp);
return $target;
}
/**
* Merge the attachments from the target and the other entity.
* @param AttachmentContainingDBElement $target
* @param AttachmentContainingDBElement $other
* @return object
*/
protected function mergeAttachments(AttachmentContainingDBElement $target, AttachmentContainingDBElement $other): object
{
return $this->mergeCollections($target, $other, 'attachments', function (Attachment $t, Attachment $o): bool {
return $t->getName() === $o->getName()
&& $t->getAttachmentType() === $o->getAttachmentType()
&& $t->getPath() === $o->getPath();
});
}
/**
* Merge the parameters from the target and the other entity.
* @param AbstractStructuralDBElement|Part $target
* @param AbstractStructuralDBElement|Part $other
* @return object
*/
protected function mergeParameters(AbstractStructuralDBElement|Part $target, AbstractStructuralDBElement|Part $other): object
{
return $this->mergeCollections($target, $other, 'parameters', function (AbstractParameter $t, AbstractParameter $o): bool {
return $t->getName() === $o->getName()
&& $t->getSymbol() === $o->getSymbol()
&& $t->getUnit() === $o->getUnit()
&& $t->getValueMax() === $o->getValueMax()
&& $t->getValueMin() === $o->getValueMin()
&& $t->getValueTypical() === $o->getValueTypical()
&& $t->getValueText() === $o->getValueText()
&& $t->getGroup() === $o->getGroup();
});
}
/**
* Check if the two strings have equal content.
* This method is case-insensitive and ignores whitespace.
* @param string|\Stringable $t
* @param string|\Stringable $o
* @return bool
*/
protected function areStringsEqual(string|\Stringable $t, string|\Stringable $o): bool
{
$t_str = u($t)->trim()->folded();
$o_str = u($o)->trim()->folded();
return $t_str->equalsTo($o_str);
}
/**
* Merge the text from the target and the other entity for the given field by attaching the other text to the target text via the given separator.
* For example, if the target text is "Hello" and the other text is "World", the result is "Hello / World".
* If the text is the same in both entities, the target text is returned.
* @param object $target
* @param object $other
* @param string $field
* @param string $separator
* @return object
*/
protected function mergeTextWithSeparator(object $target, object $other, string $field, string $separator = ' / '): object
{
return $this->useCallback(
function (string $t, string $o) use ($separator): string {
//Check if the strings are equal
if ($this->areStringsEqual($t, $o)) {
return $t;
}
//Skip empty strings
if (trim($t) === '') {
return trim($o);
}
if (trim($o) === '') {
return trim($t);
}
return trim($t) . $separator . trim($o);
},
$target,
$other,
$field
);
}
/**
* Merge two comments from the target and the other entity for the given field.
* The comments of the both entities get concated, while the second part get a headline with the name of the old part.
* @param AbstractNamedDBElement $target
* @param AbstractNamedDBElement $other
* @param string $field
* @return object
*/
protected function mergeComment(AbstractNamedDBElement $target, AbstractNamedDBElement $other, string $field = 'comment'): object
{
return $this->useCallback(
function (string $t, string $o) use ($other): string {
//Check if the strings are equal
if ($this->areStringsEqual($t, $o)) {
return $t;
}
//Skip empty strings
if (trim($t) === '') {
return trim($o);
}
if (trim($o) === '') {
return trim($t);
}
return sprintf("%s\n\n<b>%s:</b>\n%s",
trim($t),
$other->getName(),
trim($o)
);
},
$target,
$other,
$field
);
}
}

View File

@@ -0,0 +1,58 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\EntityMergers\Mergers;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/**
* @template T of object
*/
#[AutoconfigureTag('app.entity_merger')]
interface EntityMergerInterface
{
/**
* Determines if this merger supports merging the other entity into the target entity.
* @param object $target
* @phpstan-param T $target
* @param object $other
* @phpstan-param T $other
* @param array $context
* @return bool True if this merger supports merging the other entity into the target entity, false otherwise
*/
public function supports(object $target, object $other, array $context = []): bool;
/**
* Merge the other entity into the target entity.
* The target entity will be modified and returned.
* @param object $target
* @phpstan-param T $target
* @param object $other
* @phpstan-param T $other
* @param array $context
* @phpstan-return T
* @return object
*/
public function merge(object $target, object $other, array $context = []): object;
}

View File

@@ -0,0 +1,187 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\EntityMergers\Mergers;
use App\Entity\Parts\InfoProviderReference;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartAssociation;
use App\Entity\Parts\PartLot;
use App\Entity\PriceInformations\Orderdetail;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
/**
* This class merges two parts together.
*
* @implements EntityMergerInterface<Part>
*/
class PartMerger implements EntityMergerInterface
{
use EntityMergerHelperTrait;
public function supports(object $target, object $other, array $context = []): bool
{
return $target instanceof Part && $other instanceof Part;
}
public function merge(object $target, object $other, array $context = []): Part
{
if (!$target instanceof Part || !$other instanceof Part) {
throw new \InvalidArgumentException('The target and the other entity must be instances of Part');
}
//Merge basic fields
$this->mergeTextWithSeparator($target, $other, 'name');
$this->mergeTextWithSeparator($target, $other, 'description');
$this->mergeComment($target, $other);
$this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_url');
$this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_number');
$this->useOtherValueIfNotEmtpy($target, $other, 'mass');
$this->useOtherValueIfNotEmtpy($target, $other, 'ipn');
//Merge relations to other entities
$this->useOtherValueIfNotNull($target, $other, 'manufacturer');
$this->useOtherValueIfNotNull($target, $other, 'footprint');
$this->useOtherValueIfNotNull($target, $other, 'category');
$this->useOtherValueIfNotNull($target, $other, 'partUnit');
//We assume that the higher value is the correct one for minimum instock
$this->useLargerValue($target, $other, 'minamount');
//We assume that a part needs review and is a favorite if one of the parts is
$this->useTrueValue($target, $other, 'needs_review');
$this->useTrueValue($target, $other, 'favorite');
//Merge the tags using the tag merger
$this->mergeTags($target, $other, 'tags');
//Merge manufacturing status
$this->useCallback(function (?ManufacturingStatus $t, ?ManufacturingStatus $o): ManufacturingStatus {
//Use the other value, if the target value is not set
if ($t === ManufacturingStatus::NOT_SET || $t === null) {
return $o ?? ManufacturingStatus::NOT_SET;
}
return $t;
}, $target, $other, 'manufacturing_status');
//Merge provider reference
$this->useCallback(function (InfoProviderReference $t, InfoProviderReference $o): InfoProviderReference {
if (!$t->isProviderCreated() && $o->isProviderCreated()) {
return $o;
}
return $t;
}, $target, $other, 'providerReference');
//Merge the collections
$this->mergeCollectionFields($target, $other, $context);
return $target;
}
private static function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool {
//We compare the translation keys, as it contains info about the type and other type info
return $t->getOther() === $o->getOther()
&& $t->getTypeTranslationKey() === $o->getTypeTranslationKey();
}
private function mergeCollectionFields(Part $target, Part $other, array $context): void
{
/********************************************************************************
* Merge collections
********************************************************************************/
//Lots from different parts are never considered equal, so we just merge them together
$this->mergeCollections($target, $other, 'partLots');
$this->mergeAttachments($target, $other);
$this->mergeParameters($target, $other);
//Merge the associations
$this->mergeCollections($target, $other, 'associated_parts_as_owner', self::comparePartAssociations(...));
//We have to recreate the associations towards the other part, as they are not created by the merger
foreach ($other->getAssociatedPartsAsOther() as $association) {
//Clone the association
$clone = clone $association;
//Set the target part as the other part
$clone->setOther($target);
$owner = $clone->getOwner();
if (!$owner) {
continue;
}
//Ensure that the association is not already present
foreach ($owner->getAssociatedPartsAsOwner() as $existing_association) {
if (self::comparePartAssociations($existing_association, $clone)) {
continue 2;
}
}
//Add the association to the owner
$owner->addAssociatedPartsAsOwner($clone);
}
$this->mergeCollections($target, $other, 'orderdetails', function (Orderdetail $t, Orderdetail $o) {
//First check that the orderdetails infos are equal
$tmp = $t->getSupplier() === $o->getSupplier()
&& $t->getSupplierPartNr() === $o->getSupplierPartNr()
&& $t->getSupplierProductUrl(false) === $o->getSupplierProductUrl(false);
if (!$tmp) {
return false;
}
//Check if the pricedetails are equal
$t_pricedetails = $t->getPricedetails();
$o_pricedetails = $o->getPricedetails();
//Ensure that both pricedetails have the same length
if (count($t_pricedetails) !== count($o_pricedetails)) {
return false;
}
//Check if all pricedetails are equal
for ($n=0, $nMax = count($t_pricedetails); $n< $nMax; $n++) {
$t_price = $t_pricedetails->get($n);
$o_price = $o_pricedetails->get($n);
if (!$t_price->getPrice()->isEqualTo($o_price->getPrice())
|| $t_price->getCurrency() !== $o_price->getCurrency()
|| $t_price->getPriceRelatedQuantity() !== $o_price->getPriceRelatedQuantity()
|| $t_price->getMinDiscountQuantity() !== $o_price->getMinDiscountQuantity()
) {
return false;
}
}
//If all pricedetails are equal, the orderdetails are equal
return true;
});
//The pricedetails are not correctly assigned to the new orderdetails, so fix that
foreach ($target->getOrderdetails() as $orderdetail) {
foreach ($orderdetail->getPricedetails() as $pricedetail) {
$pricedetail->setOrderdetail($orderdetail);
}
}
}
}

View File

@@ -41,6 +41,8 @@ class PriceDTO
public readonly ?string $currency_iso_code,
/** @var bool If the price includes tax */
public readonly ?bool $includes_tax = true,
/** @var float the price related quantity */
public readonly ?float $price_related_quantity = 1.0,
)
{
$this->price_as_big_decimal = BigDecimal::of($this->price);
@@ -54,4 +56,4 @@ class PriceDTO
{
return $this->price_as_big_decimal;
}
}
}

View File

@@ -87,6 +87,7 @@ final class DTOtoEntityConverter
{
$entity->setMinDiscountQuantity($dto->minimum_discount_amount);
$entity->setPrice($dto->getPriceAsBigDecimal());
$entity->setPriceRelatedQuantity($dto->price_related_quantity);
//Currency TODO
if ($dto->currency_iso_code !== null) {
@@ -95,7 +96,6 @@ final class DTOtoEntityConverter
$entity->setCurrency(null);
}
return $entity;
}
@@ -289,7 +289,7 @@ final class DTOtoEntityConverter
//If the entity was newly created, set the file filter
if ($tmp->getID() === null) {
$tmp->setFiletypeFilter('image/*');
$tmp->setAlternativeNames(self::TYPE_DATASHEETS_NAME);
$tmp->setAlternativeNames(self::TYPE_IMAGE_NAME);
}
return $tmp;

View File

@@ -76,11 +76,11 @@ final class BarcodeContentGenerator
{
$type = $this->classToString(self::URL_MAP, $target);
return $this->urlGenerator->generate('scan_qr', [
'type' => $type,
return $this->urlGenerator->generate('scan_qr', [
'type' => $type,
'id' => $target->getID() ?? 0,
'_locale' => null,
], UrlGeneratorInterface::ABSOLUTE_URL);
], UrlGeneratorInterface::ABSOLUTE_URL);
}
/**

View File

@@ -0,0 +1,96 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\LabelSystem\Barcodes;
use App\Entity\LabelSystem\BarcodeType;
use Com\Tecnick\Barcode\Barcode;
/**
* This function is used to generate barcodes of various types using arbitrary (text) content.
*/
class BarcodeHelper
{
/**
* Generates a barcode with the given content and type and returns it as SVG string.
* @param string $content
* @param BarcodeType $type
* @return string
*/
public function barcodeAsSVG(string $content, BarcodeType $type): string
{
$barcode = new Barcode();
$type_str = match ($type) {
BarcodeType::NONE => throw new \InvalidArgumentException('Barcode type must not be NONE! This would make no sense...'),
BarcodeType::QR => 'QRCODE',
BarcodeType::DATAMATRIX => 'DATAMATRIX',
BarcodeType::CODE39 => 'C39',
BarcodeType::CODE93 => 'C93',
BarcodeType::CODE128 => 'C128A',
};
return $barcode->getBarcodeObj($type_str, $content)->getSvgCode();
}
/**
* Generates a barcode with the given content and type and returns it as HTML image tag.
* @param string $content
* @param BarcodeType $type
* @param string $width Width of the image tag
* @param string|null $alt_text The alt text of the image tag. If null, the content is used.
* @return string
*/
public function barcodeAsHTML(string $content, BarcodeType $type, string $width = '100%', ?string $alt_text = null): string
{
$svg = $this->barcodeAsSVG($content, $type);
$base64 = $this->dataUri($svg, 'image/svg+xml');
$alt_text = $alt_text ?? $content;
return '<img src="'.$base64.'" width="'.$width.'" style="min-height: 25px;" alt="'.$alt_text.'"/>';
}
/**
* Creates a data URI (RFC 2397).
* Based on the Twig implementation from HTMLExtension
*
* Length validation is not performed on purpose, validation should
* be done before calling this filter.
*
* @return string The generated data URI
*/
private function dataUri(string $data, string $mime): string
{
$repr = 'data:';
$repr .= $mime;
if (str_starts_with($mime, 'text/')) {
$repr .= ','.rawurlencode($data);
} else {
$repr .= ';base64,'.base64_encode($data);
}
return $repr;
}
}

View File

@@ -1,110 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Services\LabelSystem\Barcodes;
use InvalidArgumentException;
/**
* @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeNormalizerTest
*/
final class BarcodeNormalizer
{
private const PREFIX_TYPE_MAP = [
'L' => 'lot',
'P' => 'part',
'S' => 'location',
];
/**
* Parses barcode content and normalizes it.
* Returns an array in the format ['part', 1]: First entry contains element type, second the ID of the element.
*/
public function normalizeBarcodeContent(string $input): array
{
$input = trim($input);
$matches = [];
//Some scanner output '-' as ß, so replace it (ß is never used, so we can replace it safely)
$input = str_replace('ß', '-', $input);
//Extract parts from QR code's URL
if (preg_match('#^https?://.*/scan/(\w+)/(\d+)/?$#', $input, $matches)) {
return [$matches[1], (int) $matches[2]];
}
//New Code39 barcode use L0001 format
if (preg_match('#^([A-Z])(\d{4,})$#', $input, $matches)) {
$prefix = $matches[1];
$id = (int) $matches[2];
if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
throw new InvalidArgumentException('Unknown prefix '.$prefix);
}
return [self::PREFIX_TYPE_MAP[$prefix], $id];
}
//During development the L-000001 format was used
if (preg_match('#^(\w)-(\d{6,})$#', $input, $matches)) {
$prefix = $matches[1];
$id = (int) $matches[2];
if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
throw new InvalidArgumentException('Unknown prefix '.$prefix);
}
return [self::PREFIX_TYPE_MAP[$prefix], $id];
}
//Legacy Part-DB location labels used $L00336 format
if (preg_match('#^\$L(\d{5,})$#', $input, $matches)) {
return ['location', (int) $matches[1]];
}
//Legacy Part-DB used EAN8 barcodes for part labels. Format 0000001(2) (note the optional 8th digit => checksum)
if (preg_match('#^(\d{7})\d?$#', $input, $matches)) {
return ['part', (int) $matches[1]];
}
throw new InvalidArgumentException('Unknown barcode format!');
}
}

View File

@@ -41,6 +41,7 @@ declare(strict_types=1);
namespace App\Services\LabelSystem\Barcodes;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\Entity\Parts\PartLot;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
@@ -59,32 +60,30 @@ final class BarcodeRedirector
/**
* Determines the URL to which the user should be redirected, when scanning a QR code.
*
* @param string $type The type of the element that was scanned (e.g. 'part', 'lot', etc.)
* @param int $id The ID of the element that was scanned
*
* @param BarcodeScanResult $barcodeScan The result of the barcode scan
* @return string the URL to which should be redirected
*
* @throws EntityNotFoundException
*/
public function getRedirectURL(string $type, int $id): string
public function getRedirectURL(BarcodeScanResult $barcodeScan): string
{
switch ($type) {
case 'part':
return $this->urlGenerator->generate('app_part_show', ['id' => $id]);
case 'lot':
switch ($barcodeScan->target_type) {
case LabelSupportedElement::PART:
return $this->urlGenerator->generate('app_part_show', ['id' => $barcodeScan->target_id]);
case LabelSupportedElement::PART_LOT:
//Try to determine the part to the given lot
$lot = $this->em->find(PartLot::class, $id);
$lot = $this->em->find(PartLot::class, $barcodeScan->target_id);
if (!$lot instanceof PartLot) {
throw new EntityNotFoundException();
}
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID()]);
case 'location':
return $this->urlGenerator->generate('part_list_store_location', ['id' => $id]);
case LabelSupportedElement::STORELOCATION:
return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]);
default:
throw new InvalidArgumentException('Unknown $type: '.$type);
throw new InvalidArgumentException('Unknown $type: '.$barcodeScan->target_type->name);
}
}
}

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 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Services\LabelSystem\Barcodes;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
/**
* @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeNormalizerTest
*/
final class BarcodeScanHelper
{
private const PREFIX_TYPE_MAP = [
'L' => LabelSupportedElement::PART_LOT,
'P' => LabelSupportedElement::PART,
'S' => LabelSupportedElement::STORELOCATION,
];
public const QR_TYPE_MAP = [
'lot' => LabelSupportedElement::PART_LOT,
'part' => LabelSupportedElement::PART,
'location' => LabelSupportedElement::STORELOCATION,
];
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
/**
* Parse the given barcode content and return the target type and ID.
* If the barcode could not be parsed, an exception is thrown.
* Using the $type parameter, you can specify how the barcode should be parsed. If set to null, the function
* will try to guess the type.
* @param string $input
* @param BarcodeSourceType|null $type
* @return BarcodeScanResult
*/
public function scanBarcodeContent(string $input, ?BarcodeSourceType $type = null): BarcodeScanResult
{
//Do specific parsing
if ($type === BarcodeSourceType::INTERNAL) {
return $this->parseInternalBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
}
if ($type === BarcodeSourceType::VENDOR) {
return $this->parseVendorBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
}
if ($type === BarcodeSourceType::IPN) {
return $this->parseIPNBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
}
//Null means auto and we try the different formats
$result = $this->parseInternalBarcode($input);
if ($result !== null) {
return $result;
}
//Try to parse as vendor barcode
$result = $this->parseVendorBarcode($input);
if ($result !== null) {
return $result;
}
//Try to parse as IPN barcode
$result = $this->parseIPNBarcode($input);
if ($result !== null) {
return $result;
}
throw new InvalidArgumentException('Unknown barcode');
}
private function parseVendorBarcode(string $input): ?BarcodeScanResult
{
$lot_repo = $this->entityManager->getRepository(PartLot::class);
//Find only the first result
$results = $lot_repo->findBy(['vendor_barcode' => $input], limit: 1);
if (count($results) === 0) {
return null;
}
//We found a part, so use it to create the result
$lot = $results[0];
return new BarcodeScanResult(
target_type: LabelSupportedElement::PART_LOT,
target_id: $lot->getID(),
source_type: BarcodeSourceType::VENDOR
);
}
private function parseIPNBarcode(string $input): ?BarcodeScanResult
{
$part_repo = $this->entityManager->getRepository(Part::class);
//Find only the first result
$results = $part_repo->findBy(['ipn' => $input], limit: 1);
if (count($results) === 0) {
return null;
}
//We found a part, so use it to create the result
$part = $results[0];
return new BarcodeScanResult(
target_type: LabelSupportedElement::PART,
target_id: $part->getID(),
source_type: BarcodeSourceType::IPN
);
}
/**
* This function tries to interpret the given barcode content as an internal barcode.
* If the barcode could not be parsed at all, null is returned. If the barcode is a valid format, but could
* not be found in the database, an exception is thrown.
* @param string $input
* @return BarcodeScanResult|null
*/
private function parseInternalBarcode(string $input): ?BarcodeScanResult
{
$input = trim($input);
$matches = [];
//Some scanner output '-' as ß, so replace it (ß is never used, so we can replace it safely)
$input = str_replace('ß', '-', $input);
//Extract parts from QR code's URL
if (preg_match('#^https?://.*/scan/(\w+)/(\d+)/?$#', $input, $matches)) {
return new BarcodeScanResult(
target_type: self::QR_TYPE_MAP[strtolower($matches[1])],
target_id: (int) $matches[2],
source_type: BarcodeSourceType::INTERNAL
);
}
//New Code39 barcode use L0001 format
if (preg_match('#^([A-Z])(\d{4,})$#', $input, $matches)) {
$prefix = $matches[1];
$id = (int) $matches[2];
if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
throw new InvalidArgumentException('Unknown prefix '.$prefix);
}
return new BarcodeScanResult(
target_type: self::PREFIX_TYPE_MAP[$prefix],
target_id: $id,
source_type: BarcodeSourceType::INTERNAL
);
}
//During development the L-000001 format was used
if (preg_match('#^(\w)-(\d{6,})$#', $input, $matches)) {
$prefix = $matches[1];
$id = (int) $matches[2];
if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
throw new InvalidArgumentException('Unknown prefix '.$prefix);
}
return new BarcodeScanResult(
target_type: self::PREFIX_TYPE_MAP[$prefix],
target_id: $id,
source_type: BarcodeSourceType::INTERNAL
);
}
//Legacy Part-DB location labels used $L00336 format
if (preg_match('#^\$L(\d{5,})$#', $input, $matches)) {
return new BarcodeScanResult(
target_type: LabelSupportedElement::STORELOCATION,
target_id: (int) $matches[1],
source_type: BarcodeSourceType::INTERNAL
);
}
//Legacy Part-DB used EAN8 barcodes for part labels. Format 0000001(2) (note the optional 8th digit => checksum)
if (preg_match('#^(\d{7})\d?$#', $input, $matches)) {
return new BarcodeScanResult(
target_type: LabelSupportedElement::PART,
target_id: (int) $matches[1],
source_type: BarcodeSourceType::INTERNAL
);
}
//This function abstain from further parsing
return null;
}
}

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\LabelSystem\Barcodes;
use App\Entity\LabelSystem\LabelSupportedElement;
/**
* This class represents the result of a barcode scan, with the target type and the ID of the element
*/
class BarcodeScanResult
{
public function __construct(
public readonly LabelSupportedElement $target_type,
public readonly int $target_id,
public readonly BarcodeSourceType $source_type,
) {
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\LabelSystem\Barcodes;
/**
* This enum represents the different types, where a barcode/QR-code can be generated from
*/
enum BarcodeSourceType
{
/** This Barcode was generated using Part-DB internal recommended barcode generator */
case INTERNAL;
/** This barcode is containing an internal part number (IPN) */
case IPN;
/**
* This barcode is a custom barcode from a third party like a vendor, which was set via the vendor_barcode
* field of a part lot.
*/
case VENDOR;
}

View File

@@ -46,67 +46,47 @@ use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\LabelSystem\BarcodeType;
use App\Entity\LabelSystem\LabelOptions;
use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
use App\Services\LabelSystem\Barcodes\BarcodeHelper;
use Com\Tecnick\Barcode\Barcode;
use InvalidArgumentException;
/**
* @see \App\Tests\Services\LabelSystem\BarcodeGeneratorTest
*/
final class BarcodeGenerator
final class LabelBarcodeGenerator
{
public function __construct(private readonly BarcodeContentGenerator $barcodeContentGenerator)
public function __construct(private readonly BarcodeContentGenerator $barcodeContentGenerator, private readonly BarcodeHelper $barcodeHelper)
{
}
public function generateHTMLBarcode(LabelOptions $options, object $target): ?string
{
$svg = $this->generateSVG($options, $target);
$base64 = $this->dataUri($svg, 'image/svg+xml');
return '<img src="'.$base64.'" width="100%" style="min-height: 25px;" alt="'. $this->getContent($options, $target) . '" />';
}
/**
* Creates a data URI (RFC 2397).
* Based on the Twig implementaion from HTMLExtension
*
* Length validation is not performed on purpose, validation should
* be done before calling this filter.
*
* @return string The generated data URI
/**
* Generate the barcode for the given label as HTML image tag.
* @param LabelOptions $options
* @param AbstractDBElement $target
* @return string|null
*/
private function dataUri(string $data, string $mime): string
public function generateHTMLBarcode(LabelOptions $options, AbstractDBElement $target): ?string
{
$repr = 'data:';
$repr .= $mime;
if (str_starts_with($mime, 'text/')) {
$repr .= ','.rawurlencode($data);
} else {
$repr .= ';base64,'.base64_encode($data);
}
return $repr;
}
public function generateSVG(LabelOptions $options, object $target): ?string
{
$barcode = new Barcode();
$type = match ($options->getBarcodeType()) {
BarcodeType::NONE => null,
BarcodeType::QR => 'QRCODE',
BarcodeType::DATAMATRIX => 'DATAMATRIX',
BarcodeType::CODE39 => 'C39',
BarcodeType::CODE93 => 'C93',
BarcodeType::CODE128 => 'C128A',
};
if ($type === null) {
if ($options->getBarcodeType() === BarcodeType::NONE) {
return null;
}
return $this->barcodeHelper->barcodeAsHTML($this->getContent($options, $target), $options->getBarcodeType());
}
return $barcode->getBarcodeObj($type, $this->getContent($options, $target))->getSvgCode();
/**
* Generate the barcode for the given label as SVG string.
* @param LabelOptions $options
* @param AbstractDBElement $target
* @return string|null
*/
public function generateSVG(LabelOptions $options, AbstractDBElement $target): ?string
{
if ($options->getBarcodeType() === BarcodeType::NONE) {
return null;
}
return $this->barcodeHelper->barcodeAsSVG($this->getContent($options, $target), $options->getBarcodeType());
}
public function getContent(LabelOptions $options, AbstractDBElement $target): ?string

View File

@@ -53,7 +53,7 @@ use Twig\Error\Error;
final class LabelHTMLGenerator
{
public function __construct(private readonly ElementTypeNameGenerator $elementTypeNameGenerator, private readonly LabelTextReplacer $replacer, private readonly Environment $twig, private readonly BarcodeGenerator $barcodeGenerator, private readonly SandboxedTwigProvider $sandboxedTwigProvider, private readonly Security $security, private readonly string $partdb_title)
public function __construct(private readonly ElementTypeNameGenerator $elementTypeNameGenerator, private readonly LabelTextReplacer $replacer, private readonly Environment $twig, private readonly LabelBarcodeGenerator $barcodeGenerator, private readonly SandboxedTwigProvider $sandboxedTwigProvider, private readonly Security $security, private readonly string $partdb_title)
{
}

View File

@@ -24,12 +24,18 @@ namespace App\Services\LabelSystem\PlaceholderProviders;
use App\Entity\LabelSystem\BarcodeType;
use App\Entity\LabelSystem\LabelOptions;
use App\Services\LabelSystem\BarcodeGenerator;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Services\LabelSystem\Barcodes\BarcodeHelper;
use App\Services\LabelSystem\LabelBarcodeGenerator;
use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
use Com\Tecnick\Barcode\Exception;
final class BarcodeProvider implements PlaceholderProviderInterface
{
public function __construct(private readonly BarcodeGenerator $barcodeGenerator, private readonly BarcodeContentGenerator $barcodeContentGenerator)
public function __construct(private readonly LabelBarcodeGenerator $barcodeGenerator,
private readonly BarcodeContentGenerator $barcodeContentGenerator,
private readonly BarcodeHelper $barcodeHelper)
{
}
@@ -69,6 +75,37 @@ final class BarcodeProvider implements PlaceholderProviderInterface
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
}
if (($label_target instanceof Part || $label_target instanceof PartLot)
&& str_starts_with($placeholder, '[[IPN_BARCODE_')) {
if ($label_target instanceof PartLot) {
$label_target = $label_target->getPart();
}
if ($label_target === null || $label_target->getIPN() === null || $label_target->getIPN() === '') {
//Replace with empty result, if no IPN is set
return '';
}
try {
//Add placeholders for the IPN barcode
if ('[[IPN_BARCODE_C39]]' === $placeholder) {
return $this->barcodeHelper->barcodeAsHTML($label_target->getIPN(), BarcodeType::CODE39);
}
if ('[[IPN_BARCODE_C128]]' === $placeholder) {
return $this->barcodeHelper->barcodeAsHTML($label_target->getIPN(), BarcodeType::CODE128);
}
if ('[[IPN_BARCODE_QR]]' === $placeholder) {
return $this->barcodeHelper->barcodeAsHTML($label_target->getIPN(), BarcodeType::QR);
}
} catch (Exception $e) {
//If an error occurs, output it
return '<b>IPN Barcode ERROR!</b>: '.$e->getMessage();
}
}
return null;
}
}

View File

@@ -193,6 +193,10 @@ class LogEntryExtraFormatter
htmlspecialchars($this->elementTypeNameGenerator->getLocalizedTypeLabel(PartLot::class))
.' ' . $context->getMoveToTargetID();
}
if ($context->getActionTimestamp()) {
$formatter = new \IntlDateFormatter($this->translator->getLocale(), \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT);
$array['log.part_stock_changed.timestamp'] = $formatter->format($context->getActionTimestamp());
}
}
return $array;

View File

@@ -9,13 +9,15 @@ use App\Entity\LogSystem\PartStockChangedLogEntry;
use App\Entity\Parts\PartLot;
use App\Services\LogSystem\EventCommentHelper;
use App\Services\LogSystem\EventLogger;
use Doctrine\ORM\EntityManagerInterface;
/**
* @see \App\Tests\Services\Parts\PartLotWithdrawAddHelperTest
*/
final class PartLotWithdrawAddHelper
{
public function __construct(private readonly EventLogger $eventLogger, private readonly EventCommentHelper $eventCommentHelper)
public function __construct(private readonly EventLogger $eventLogger,
private readonly EventCommentHelper $eventCommentHelper, private EntityManagerInterface $entityManager)
{
}
@@ -53,9 +55,10 @@ final class PartLotWithdrawAddHelper
* @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased)
* @param float $amount The amount of parts that should be taken from the part lot
* @param string|null $comment The optional comment describing the reason for the withdrawal
* @return PartLot The modified part lot
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
* @param bool $delete_lot_if_empty If true, the part lot will be deleted if the amount is 0 after the withdrawal.
*/
public function withdraw(PartLot $partLot, float $amount, ?string $comment = null): PartLot
public function withdraw(PartLot $partLot, float $amount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null, bool $delete_lot_if_empty = false): void
{
//Ensure that amount is positive
if ($amount <= 0) {
@@ -83,7 +86,7 @@ final class PartLotWithdrawAddHelper
$oldAmount = $partLot->getAmount();
$partLot->setAmount($oldAmount - $amount);
$event = PartStockChangedLogEntry::withdraw($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment);
$event = PartStockChangedLogEntry::withdraw($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment, $action_timestamp);
$this->eventLogger->log($event);
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
@@ -91,7 +94,9 @@ final class PartLotWithdrawAddHelper
$this->eventCommentHelper->setMessage($comment);
}
return $partLot;
if ($delete_lot_if_empty && $partLot->getAmount() === 0.0) {
$this->entityManager->remove($partLot);
}
}
/**
@@ -100,9 +105,10 @@ final class PartLotWithdrawAddHelper
* @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased)
* @param float $amount The amount of parts that should be taken from the part lot
* @param string|null $comment The optional comment describing the reason for the withdrawal
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
* @return PartLot The modified part lot
*/
public function add(PartLot $partLot, float $amount, ?string $comment = null): PartLot
public function add(PartLot $partLot, float $amount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null): PartLot
{
if ($amount <= 0) {
throw new \InvalidArgumentException('Amount must be positive');
@@ -123,7 +129,7 @@ final class PartLotWithdrawAddHelper
$oldAmount = $partLot->getAmount();
$partLot->setAmount($oldAmount + $amount);
$event = PartStockChangedLogEntry::add($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment);
$event = PartStockChangedLogEntry::add($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment, $action_timestamp);
$this->eventLogger->log($event);
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
@@ -141,8 +147,10 @@ final class PartLotWithdrawAddHelper
* @param PartLot $target The part lot to which the parts should be added
* @param float $amount The amount of parts that should be moved
* @param string|null $comment A comment describing the reason for the move
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
* @param bool $delete_lot_if_empty If true, the part lot will be deleted if the amount is 0 after the withdrawal.
*/
public function move(PartLot $origin, PartLot $target, float $amount, ?string $comment = null): void
public function move(PartLot $origin, PartLot $target, float $amount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null, bool $delete_lot_if_empty = false): void
{
if ($amount <= 0) {
throw new \InvalidArgumentException('Amount must be positive');
@@ -177,12 +185,16 @@ final class PartLotWithdrawAddHelper
//And add it to the target
$target->setAmount($target->getAmount() + $amount);
$event = PartStockChangedLogEntry::move($origin, $oldOriginAmount, $origin->getAmount(), $part->getAmountSum() , $comment, $target);
$event = PartStockChangedLogEntry::move($origin, $oldOriginAmount, $origin->getAmount(), $part->getAmountSum() , $comment, $target, $action_timestamp);
$this->eventLogger->log($event);
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
if (!$this->eventCommentHelper->isMessageSet() && ($comment !== null && $comment !== '')) {
$this->eventCommentHelper->setMessage($comment);
}
if ($delete_lot_if_empty && $origin->getAmount() === 0.0) {
$this->entityManager->remove($origin);
}
}
}

View File

@@ -230,12 +230,16 @@ class PermissionManager
/**
* This functions sets all operations mentioned in the alsoSet value of a permission, so that the structure is always valid.
* This function should be called after every setPermission() call.
* @return bool true if values were changed/corrected, false if not
*/
public function ensureCorrectSetOperations(HasPermissionsInterface $user): void
public function ensureCorrectSetOperations(HasPermissionsInterface $user): bool
{
//If we have changed anything on the permission structure due to the alsoSet value, this becomes true, so we
//redo the whole process, to ensure that all alsoSet values are set recursively.
$return_value = false;
do {
$anything_changed = false; //Reset the variable for the next iteration
@@ -254,12 +258,15 @@ class PermissionManager
$this->setPermission($user, $set_perm, $set_op, true);
//Mark the change, so we redo the whole process
$anything_changed = true;
$return_value = true;
}
}
}
}
}
} while($anything_changed);
return $return_value;
}
/**

View File

@@ -22,15 +22,21 @@ declare(strict_types=1);
namespace App\Validator\Constraints;
use App\Controller\GroupController;
use App\Controller\UserController;
use App\Security\Interfaces\HasPermissionsInterface;
use App\Services\UserSystem\PermissionManager;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use function Symfony\Component\Translation\t;
class ValidPermissionValidator extends ConstraintValidator
{
public function __construct(protected PermissionManager $resolver)
public function __construct(protected PermissionManager $resolver, protected RequestStack $requestStack)
{
}
@@ -49,6 +55,26 @@ class ValidPermissionValidator extends ConstraintValidator
/** @var HasPermissionsInterface $perm_holder */
$perm_holder = $this->context->getObject();
$this->resolver->ensureCorrectSetOperations($perm_holder);
$changed = $this->resolver->ensureCorrectSetOperations($perm_holder);
//Sending a flash message if the permissions were fixed (only if called from UserController or GroupController)
//This is pretty hacky and bad design but I dont see a better way without a complete rewrite of how permissions are validated
//on the admin pages
if ($changed) {
//Check if this was called in context of UserController
$request = $this->requestStack->getMainRequest();
if (!$request) {
return;
}
//Determine the controller class (the part before the ::)
$controller_class = explode('::', $request->attributes->get('_controller'))[0];
if (in_array($controller_class, [UserController::class, GroupController::class], true)) {
/** @var Session $session */
$session = $this->requestStack->getSession();
$flashBag = $session->getFlashBag();
$flashBag->add('warning', t('user.edit.flash.permissions_fixed'));
}
}
}
}

View File

@@ -7,62 +7,62 @@
<div class="dropdown-menu" aria-labelledby="navbar-search-options">
<div class="px-2" style="width: max-content;">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_name" name="name" value="1" checked>
<input type="checkbox" class="form-check-input" id="search_name" name="name" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_name" class="form-check-label justify-content-start">{% trans %}name.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_category" name="category" value="1" checked>
<input type="checkbox" class="form-check-input" id="search_category" name="category" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_category" class="form-check-label justify-content-start">{% trans %}category.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_description" name="description" value="1" checked>
<input type="checkbox" class="form-check-input" id="search_description" name="description" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_description" class="form-check-label justify-content-start">{% trans %}description.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_mpn" name="mpn" value="1" checked>
<input type="checkbox" class="form-check-input" id="search_mpn" name="mpn" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_mpn" class="form-check-label justify-content-start">{% trans %}part.edit.mpn{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_tags" name="tags" value="1" checked>
<input type="checkbox" class="form-check-input" id="search_tags" name="tags" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_tags" class="form-check-label justify-content-start">{% trans %}tags.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_storelocation" name="storelocation" value="1" checked>
<input type="checkbox" class="form-check-input" id="search_storelocation" name="storelocation" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_storelocation" class="form-check-label justify-content-start">{% trans %}storelocation.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_comment" name="comment" value="1" checked>
<input type="checkbox" class="form-check-input" id="search_comment" name="comment" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_comment" class="form-check-label justify-content-start">{% trans %}comment.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_ipn" name="ipn" value="1" checked>
<input type="checkbox" class="form-check-input" id="search_ipn" name="ipn" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_ipn" class="form-check-label justify-content-start">{% trans %}part.edit.ipn{% endtrans %}</label>
</div>
{% if true %}
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_supplierpartnr" name="ordernr" value="1" checked>
<input type="checkbox" class="form-check-input" id="search_supplierpartnr" name="ordernr" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_supplierpartnr" class="form-check-label justify-content-start">{% trans %}orderdetails.edit.supplierpartnr{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_supplier" name="supplier" value="1">
<input type="checkbox" class="form-check-input" id="search_supplier" name="supplier" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_supplier" class="form-check-label justify-content-start">{% trans %}supplier.label{% endtrans %}</label>
</div>
{% endif %}
{% if true %}
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_manufacturer" name="manufacturer" value="1">
<input type="checkbox" class="form-check-input" id="search_manufacturer" name="manufacturer" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_manufacturer" class="form-check-label justify-content-start">{% trans %}manufacturer.label{% endtrans %}</label>
</div>
{% endif %}
{% if true %}
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_footprint" name="footprint" value="1">
<input type="checkbox" class="form-check-input" id="search_footprint" name="footprint" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_footprint" class="form-check-label justify-content-start">{% trans %}footprint.label{% endtrans %}</label>
</div>
{% endif %}
<hr>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="regex" name="regex" value="1">
<input type="checkbox" class="form-check-input" id="regex" name="regex" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="regex" class="form-check-label justify-content-start">{% trans %}search.regexmatching{% endtrans %}</label>
</div>
</div>

View File

@@ -23,7 +23,7 @@
<div class="d-none" {{ stimulus_controller('turbo/locale_menu') }}>
{% for locale in locale_menu %}
<a class="dropdown-item" data-turbo="false" data-turbo-frame="_top" href="{{ path(app.request.attributes.get('_route'),
app.request.attributes.get('_route_params')|merge({'_locale': locale})) }}">
app.request.query.all|merge(app.request.attributes.get('_route_params'))|merge({'_locale': locale})) }}">
{{ locale|language_name }} ({{ locale|upper }})</a>
{% endfor %}
</div>

View File

@@ -36,7 +36,7 @@
{% if entity.buildPart %}
<span class="form-control-static"><a href="{{ entity_url(entity.buildPart) }}">{{ entity.buildPart.name }}</a></span>
{% else %}
<a href="{{ path('part_new_build_part', {"project_id": entity.id , "_redirect": app.request.requestUri}) }}"
<a href="{{ path('part_new_build_part', {"project_id": entity.id , "_redirect": app.request.baseUrl ~ app.request.requestUri}) }}"
class="btn btn-outline-success">{% trans %}project.edit.associated_build_part.add{% endtrans %}</a>
{% endif %}
<p class="text-muted">{% trans %}project.edit.associated_build.hint{% endtrans %}</p>

View File

@@ -29,4 +29,13 @@
{% macro delete_btn() %}
{{ stimulus_action('elements/collection_type', 'deleteElement') }}
{% endmacro %}
{% macro new_element_indicator(value) %}
{% if value.id is not defined or value.id is null %}
<span class="position-absolute top-0 start-100 translate-middle p-2 bg-primary border border-light rounded-circle"
title="{% trans %}collection_type.new_element.tooltip{% endtrans %}">
<span class="visually-hidden">New alerts</span>
</span>
{% endif %}
{% endmacro %}

View File

@@ -19,7 +19,8 @@
{% macro partsDatatableWithForm(datatable, state_save_tag = 'parts') %}
<form method="post" action="{{ path("table_action") }}"
{{ stimulus_controller('elements/datatables/parts', {"stateSaveTag": state_save_tag}) }} data-dt-settings='{{ datatable_settings(datatable)|escape('html_attr') }}' data-dt-url="{{ app.request.requestUri }}"
{# The app.request.baseUrl here is important or it wont work behind a reverse proxy with subfolder #}
{{ stimulus_controller('elements/datatables/parts', {"stateSaveTag": state_save_tag}) }} data-dt-settings='{{ datatable_settings(datatable)|escape('html_attr') }}' data-dt-url="{{ app.request.baseUrl ~ app.request.requestUri }}"
{{ stimulus_action('elements/datatables/parts', 'confirmDeletionAtSubmit') }} data-delete-title="{% trans %}part_list.action.delete-title{% endtrans %}"
data-delete-message="{% trans %}part_list.action.delete-message{% endtrans %}">
<input type="hidden" name="_token" value="{{ csrf_token('table_action') }}">

View File

@@ -17,7 +17,7 @@
{{ stimulus_controller('elements/delete_btn') }} {{ stimulus_action('elements/delete_btn', "submit", "submit") }}
data-delete-title="{% trans %}log.undo.confirm_title{% endtrans %}"
data-delete-message="{% trans %}log.undo.confirm_message{% endtrans %}">
<input type="hidden" name="redirect_back" value="{{ app.request.requestUri }}">
<input type="hidden" name="redirect_back" value="{{ app.request.baseUrl ~ app.request.requestUri }}">
{{ datatables.logDataTable(datatable, tag) }}
</form>

View File

@@ -50,8 +50,9 @@
{{ form_errors(form.name) }}
</td>
<td>
<button type="button" class="btn btn-danger lot_btn_delete" {{ collection.delete_btn() }}>
<button type="button" class="btn btn-danger lot_btn_delete position-relative" {{ collection.delete_btn() }}>
<i class="fas fa-trash-alt fa-fw"></i>
{{ collection.new_element_indicator(value) }}
</button>
{{ form_errors(form) }}
</td>

View File

@@ -89,6 +89,10 @@
{% endif %}
</div>
{% if show_dependency_notice %}
<small class="text-muted mb-1 d-inline-block">{% trans %}permission.legend.dependency_note{% endtrans %}</small>
{% endif %}
<ul class="nav nav-tabs">
{% for group in form %}
<li class="nav-item">

View File

@@ -188,6 +188,15 @@
{% endif %}
{% endmacro %}
{% macro part_icon_link(part) %}
{% set preview_attach = part_preview_generator.tablePreviewAttachment(part) %}
{% if preview_attach %}
<img src="{{ attachment_thumbnail(preview_attach, 'thumbnail_xs') }}" class="entity-image-xs" alt="Part image"
{{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ attachment_thumbnail(preview_attach) }}">
{% endif %}
<a href="{{ entity_url(part) }}">{{ part.name }}</a>
{% endmacro %}
{% macro entity_preview_sm(entity) %}
{# @var entity \App\Entity\Contracts\HasMasterAttachmentInterface #}
{% if entity.masterPictureAttachment and entity.masterPictureAttachment.picture and attachment_manager.fileExisting(entity.masterPictureAttachment) %}

View File

@@ -3,16 +3,25 @@
{% import "info_providers/providers.macro.html.twig" as providers_macro %}
{% import "helper.twig" as helper %}
{% block title %}{% trans %}info_providers.search.title{% endtrans %}{% endblock %}
{% block title %}
{% if update_target %}
{% trans %}info_providers.update_part.title{% endtrans %}
{% else %}
{% trans %}info_providers.search.title{% endtrans %}
{% endif %}
{% endblock %}
{% block card_title %}
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.search.title{% endtrans %}
{% if update_target %} {# If update_target is set, we update an existing part #}
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.update_part.title{% endtrans %}:
<b><a href="{{ entity_url(update_target) }}" target="_blank" class="text-bg-primary">{{ update_target.name }}</a></b>
{% else %} {# Create a fresh part #}
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.search.title{% endtrans %}
{% endif %}
{% endblock %}
{% block card_content %}
{{ form_start(form) }}
{{ form_row(form.keyword) }}
@@ -86,7 +95,15 @@
<br>
<small class="text-muted">{{ result.provider_id }}</small>
<td>
<a class="btn btn-primary" href="{{ path('info_providers_create_part', {'providerKey': result.provider_key, 'providerId': result.provider_id}) }}"
{% if update_target %} {# We update an existing part #}
{% set href = path('info_providers_update_part',
{'providerKey': result.provider_key, 'providerId': result.provider_id, 'id': update_target.iD}) %}
{% else %} {# Create a fresh part #}
{% set href = path('info_providers_create_part',
{'providerKey': result.provider_key, 'providerId': result.provider_id}) %}
{% endif %}
<a class="btn btn-primary" href="{{ href }}"
target="_blank" title="{% trans %}part.create.btn{% endtrans %}">
<i class="fa-solid fa-plus-square"></i>
</a>

View File

@@ -22,7 +22,7 @@
data-delete-title="{% trans %}log.undo.confirm_title{% endtrans %}"
data-delete-message="{% trans %}log.undo.confirm_message{% endtrans %}">
<input type="hidden" name="redirect_back" value="{{ app.request.requestUri }}">
<input type="hidden" name="redirect_back" value="{{ app.request.baseUrl ~ app.request.requestUri }}">
<div class="btn-group btn-group-sm" role="group">
<button type="submit" class="btn btn-outline-secondary" name="undo" value="{{ entry.id }}" {% if disabled %}disabled{% endif %}>

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