mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-20 16:52:41 +01:00
Compare commits
209 Commits
copilot/re
...
gtin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35598df354 | ||
|
|
3c87fe0932 | ||
|
|
d8fdaa9529 | ||
|
|
2f9601364e | ||
|
|
e5231e29f2 | ||
|
|
8ac8743792 | ||
|
|
586375d921 | ||
|
|
4740b6d19e | ||
|
|
5a47b15c97 | ||
|
|
3bff5fa8bd | ||
|
|
f95e39748e | ||
|
|
90c82aab2e | ||
|
|
a4c2b8f885 | ||
|
|
2c56ec746c | ||
|
|
35e844dd7b | ||
|
|
4de6dbba27 | ||
|
|
a962e5e019 | ||
|
|
1130f71075 | ||
|
|
fd76ca12fc | ||
|
|
57c8368b5e | ||
|
|
7fd7697c02 | ||
|
|
c2a51e57b7 | ||
|
|
cae0cd8ac1 | ||
|
|
f5841cc697 | ||
|
|
8104c474b7 | ||
|
|
dcdc990af1 | ||
|
|
aec53bd1dd | ||
|
|
81dde6fa68 | ||
|
|
b144f5e383 | ||
|
|
fd4eb72eb2 | ||
|
|
44204b9dbb | ||
|
|
7bffe66b73 | ||
|
|
061af28c48 | ||
|
|
851055bdb4 | ||
|
|
7d19ed3ca8 | ||
|
|
b48de83a32 | ||
|
|
518953ad45 | ||
|
|
ea748dc469 | ||
|
|
c027f9ab03 | ||
|
|
bc28eb9473 | ||
|
|
7eafa7da14 | ||
|
|
1601382b41 | ||
|
|
5ceadc8353 | ||
|
|
36e105afa8 | ||
|
|
c34acfe523 | ||
|
|
e83e7398a2 | ||
|
|
984529bc79 | ||
|
|
cad5261aba | ||
|
|
a755287c3b | ||
|
|
9ca1834d9b | ||
|
|
1a06432cec | ||
|
|
58d574a33a | ||
|
|
1adfec16e2 | ||
|
|
903716ad62 | ||
|
|
427778e4eb | ||
|
|
9b0841081b | ||
|
|
f327688f0a | ||
|
|
0e5a73b6f4 | ||
|
|
d06df4410d | ||
|
|
883e3b271d | ||
|
|
29a08d152a | ||
|
|
2b94ff952c | ||
|
|
7a856bf6f1 | ||
|
|
720c1e51e8 | ||
|
|
1ccc3ad440 | ||
|
|
68ff0721ce | ||
|
|
6dbead6d10 | ||
|
|
7ff07a7ab4 | ||
|
|
1bfd36ccf5 | ||
|
|
7e486a93c9 | ||
|
|
599145886b | ||
|
|
0826acbd52 | ||
|
|
04e8229799 | ||
|
|
a1396c6696 | ||
|
|
24f0f0d23c | ||
|
|
10acc2e130 | ||
|
|
47295bda29 | ||
|
|
f369e14f2f | ||
|
|
10c192edd1 | ||
|
|
6b27f3aa14 | ||
|
|
79f88c66d6 | ||
|
|
47c7ee9f07 | ||
|
|
909cab0044 | ||
|
|
722eb7ddab | ||
|
|
071f6f8591 | ||
|
|
7feba634b8 | ||
|
|
1213f82cdf | ||
|
|
d868225260 | ||
|
|
52be548170 | ||
|
|
73dbe64a83 | ||
|
|
b89e878871 | ||
|
|
14981200c8 | ||
|
|
8aadc0bb53 | ||
|
|
0eba4738ed | ||
|
|
a78ca675b3 | ||
|
|
6ac7a42cca | ||
|
|
a355bda9da | ||
|
|
584643d4ca | ||
|
|
2534c84039 | ||
|
|
ed39710f7f | ||
|
|
df3f069a76 | ||
|
|
c0babfa401 | ||
|
|
cd7cd6cdd3 | ||
|
|
6d224a4a9f | ||
|
|
fa04fface3 | ||
|
|
2f8553303d | ||
|
|
f168b2a83c | ||
|
|
98937974c9 | ||
|
|
6f4dad98d9 | ||
|
|
22cf04585b | ||
|
|
6628333675 | ||
|
|
fa4ae6345c | ||
|
|
1637fd63f4 | ||
|
|
0bfbbc961d | ||
|
|
97e3b0aa09 | ||
|
|
87352ca6f7 | ||
|
|
42fe781ef8 | ||
|
|
3ed62f5cee | ||
|
|
7ab33c859b | ||
|
|
705e71f1eb | ||
|
|
ae4c0786b2 | ||
|
|
3aad70934b | ||
|
|
e15d12c0bf | ||
|
|
ff7fa67682 | ||
|
|
2b723e05ff | ||
|
|
a8d2204c7f | ||
|
|
29050178bd | ||
|
|
af61772c88 | ||
|
|
b91cd44926 | ||
|
|
c476c98d56 | ||
|
|
fe458b7ff1 | ||
|
|
7b8f3aaf62 | ||
|
|
d93dfd577e | ||
|
|
4095d0fd49 | ||
|
|
6d3197497e | ||
|
|
f438a8b4cd | ||
|
|
56fa2a9396 | ||
|
|
3975a3ba61 | ||
|
|
aa9aedc5fd | ||
|
|
766ba07105 | ||
|
|
d0b827c2c3 | ||
|
|
cd7dbd5f7b | ||
|
|
8efbca798a | ||
|
|
dd6c20780b | ||
|
|
af81e15ef2 | ||
|
|
09cc2ba8ff | ||
|
|
131023da67 | ||
|
|
4636aa4e0d | ||
|
|
006cfd7b5d | ||
|
|
86f53b2956 | ||
|
|
1923abecdf | ||
|
|
a3d992a016 | ||
|
|
6402cfe619 | ||
|
|
ea71fcd120 | ||
|
|
82e3e31277 | ||
|
|
0d4f935b43 | ||
|
|
0205dd523b | ||
|
|
0a8199d81f | ||
|
|
3f6a6cc767 | ||
|
|
33a3dc6203 | ||
|
|
1cd0b459be | ||
|
|
6828ce5803 | ||
|
|
644a44e8e9 | ||
|
|
6c3e4d7880 | ||
|
|
aefb69c51e | ||
|
|
300ee33be2 | ||
|
|
64efca4786 | ||
|
|
ddbfc87ce1 | ||
|
|
3454fa51de | ||
|
|
343ad6beff | ||
|
|
d385303a52 | ||
|
|
00b35e3306 | ||
|
|
e0a25009d9 | ||
|
|
3f0e4b09ce | ||
|
|
96a37a0cb0 | ||
|
|
3e071f2b74 | ||
|
|
2157916e9b | ||
|
|
be35c36c58 | ||
|
|
7116c2ceb9 | ||
|
|
89322d329c | ||
|
|
c1d4ce77db | ||
|
|
bba3bd90a9 | ||
|
|
eaaf3ac75c | ||
|
|
8957e55a9e | ||
|
|
a232671302 | ||
|
|
5a53423594 | ||
|
|
390206f529 | ||
|
|
74862c7bb8 | ||
|
|
0e61a84ea6 | ||
|
|
3e380f82d2 | ||
|
|
a5d7a5f1d3 | ||
|
|
876cfc0375 | ||
|
|
641c8388c1 | ||
|
|
2f580c92d1 | ||
|
|
402edf096d | ||
|
|
f467002619 | ||
|
|
98b8c5b788 | ||
|
|
e0feda4e46 | ||
|
|
6fcdc0b0c3 | ||
|
|
225e347c24 | ||
|
|
fb805e2e0a | ||
|
|
8548237522 | ||
|
|
77819af9a8 | ||
|
|
68217f50c4 | ||
|
|
d42f728fad | ||
|
|
b1210bc3b5 | ||
|
|
045362de0e | ||
|
|
6a5039326c | ||
|
|
bee1542cce |
11
.env
11
.env
@@ -59,6 +59,17 @@ ERROR_PAGE_ADMIN_EMAIL=''
|
||||
# If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them...
|
||||
ERROR_PAGE_SHOW_HELP=1
|
||||
|
||||
###################################################################################
|
||||
# Update Manager settings
|
||||
###################################################################################
|
||||
|
||||
# Disable web-based updates from the Update Manager UI (0=enabled, 1=disabled).
|
||||
# When disabled, use the CLI command "php bin/console partdb:update" instead.
|
||||
DISABLE_WEB_UPDATES=1
|
||||
|
||||
# Disable backup restore from the Update Manager UI (0=enabled, 1=disabled).
|
||||
# Restoring backups is a destructive operation that could overwrite your database.
|
||||
DISABLE_BACKUP_RESTORE=1
|
||||
|
||||
###################################################################################
|
||||
# SAML Single sign on-settings
|
||||
|
||||
8
.github/workflows/assets_artifact_build.yml
vendored
8
.github/workflows/assets_artifact_build.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
@@ -80,13 +80,13 @@ jobs:
|
||||
run: zip -r /tmp/partdb_assets.zip public/build/ vendor/
|
||||
|
||||
- name: Upload assets artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Only dependencies and built assets
|
||||
path: /tmp/partdb_assets.zip
|
||||
|
||||
- name: Upload full artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Full Part-DB including dependencies and built assets
|
||||
path: /tmp/partdb_with_assets.zip
|
||||
|
||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
|
||||
@@ -46,13 +46,11 @@ RUN apt-get update && apt-get -y install \
|
||||
&& rm -rvf /var/www/html/*
|
||||
|
||||
# Install node and yarn
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
|
||||
curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
RUN curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get update && apt-get install -y \
|
||||
nodejs \
|
||||
yarn \
|
||||
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*
|
||||
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* && \
|
||||
npm install -g yarn
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -14,31 +14,21 @@ RUN apt-get update && apt-get -y install \
|
||||
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
RUN set -eux; \
|
||||
# Prepare keyrings directory
|
||||
mkdir -p /etc/apt/keyrings; \
|
||||
\
|
||||
# Import Yarn GPG key
|
||||
curl -fsSL https://dl.yarnpkg.com/debian/pubkey.gpg \
|
||||
| tee /etc/apt/keyrings/yarn.gpg >/dev/null; \
|
||||
chmod 644 /etc/apt/keyrings/yarn.gpg; \
|
||||
\
|
||||
# Add Yarn repo with signed-by
|
||||
echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian stable main" \
|
||||
| tee /etc/apt/sources.list.d/yarn.list; \
|
||||
\
|
||||
# Run NodeSource setup script (unchanged)
|
||||
# Run NodeSource setup script
|
||||
curl -sL https://deb.nodesource.com/setup_22.x | bash -; \
|
||||
\
|
||||
# Install Node.js + Yarn
|
||||
# Install Node.js
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
nodejs \
|
||||
yarn; \
|
||||
nodejs; \
|
||||
\
|
||||
# Cleanup
|
||||
apt-get -y autoremove; \
|
||||
apt-get clean autoclean; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
\
|
||||
# Install Yarn via npm
|
||||
npm install -g yarn
|
||||
|
||||
|
||||
# Install PHP
|
||||
|
||||
55
assets/controllers/backup_restore_controller.js
Normal file
55
assets/controllers/backup_restore_controller.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/**
|
||||
* Stimulus controller for backup restore confirmation dialogs.
|
||||
* Shows a confirmation dialog with backup details before allowing restore.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
filename: { type: String, default: '' },
|
||||
date: { type: String, default: '' },
|
||||
confirmTitle: { type: String, default: 'Restore Backup' },
|
||||
confirmMessage: { type: String, default: 'Are you sure you want to restore from this backup?' },
|
||||
confirmWarning: { type: String, default: 'This will overwrite your current database. This action cannot be undone!' },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.element.addEventListener('submit', this.handleSubmit.bind(this));
|
||||
}
|
||||
|
||||
handleSubmit(event) {
|
||||
// Always prevent default first
|
||||
event.preventDefault();
|
||||
|
||||
// Build confirmation message
|
||||
const message = this.confirmTitleValue + '\n\n' +
|
||||
'Backup: ' + this.filenameValue + '\n' +
|
||||
'Date: ' + this.dateValue + '\n\n' +
|
||||
this.confirmMessageValue + '\n\n' +
|
||||
'⚠️ ' + this.confirmWarningValue;
|
||||
|
||||
// Only submit if user confirms
|
||||
if (confirm(message)) {
|
||||
this.element.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
import * as bootbox from "bootbox";
|
||||
import "../../css/components/bootbox_extensions.css";
|
||||
import accept from "attr-accept";
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
@@ -73,16 +74,24 @@ export default class extends Controller {
|
||||
const newElementStr = this.htmlDecode(prototype.replace(regex, this.generateUID()));
|
||||
|
||||
|
||||
let ret = null;
|
||||
|
||||
//Insert new html after the last child element
|
||||
//If the table has a tbody, insert it there
|
||||
//Afterwards return the newly created row
|
||||
if(targetTable.tBodies[0]) {
|
||||
targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr);
|
||||
return targetTable.tBodies[0].lastElementChild;
|
||||
ret = targetTable.tBodies[0].lastElementChild;
|
||||
} else { //Otherwise just insert it
|
||||
targetTable.insertAdjacentHTML('beforeend', newElementStr);
|
||||
return targetTable.lastElementChild;
|
||||
ret = targetTable.lastElementChild;
|
||||
}
|
||||
|
||||
//Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it
|
||||
targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true}));
|
||||
|
||||
return ret;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +121,33 @@ export default class extends Controller {
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
rowInput.files = dataTransfer.files;
|
||||
|
||||
//Check the file extension and find the corresponding attachment type based on the data-filetype_filter attribute
|
||||
const attachmentTypeSelect = newElement.querySelector("select");
|
||||
if (attachmentTypeSelect) {
|
||||
let foundMatch = false;
|
||||
for (let j = 0; j < attachmentTypeSelect.options.length; j++) {
|
||||
const option = attachmentTypeSelect.options[j];
|
||||
//skip disabled options
|
||||
if (option.disabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filter = option.getAttribute('data-filetype_filter');
|
||||
if (filter) {
|
||||
if (accept({name: file.name, type: file.type}, filter)) {
|
||||
attachmentTypeSelect.value = option.value;
|
||||
foundMatch = true;
|
||||
break;
|
||||
}
|
||||
} else { //If no filter is set, chose this option until we find a better match
|
||||
if (!foundMatch) {
|
||||
attachmentTypeSelect.value = option.value;
|
||||
foundMatch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
@@ -189,4 +225,4 @@ export default class extends Controller {
|
||||
del();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,6 @@ import {marked} from "marked";
|
||||
|
||||
import {
|
||||
trans,
|
||||
SEARCH_PLACEHOLDER,
|
||||
SEARCH_SUBMIT,
|
||||
STATISTICS_PARTS
|
||||
} from '../../translator';
|
||||
|
||||
|
||||
@@ -82,9 +79,9 @@ export default class extends Controller {
|
||||
panelPlacement: this.element.dataset.panelPlacement,
|
||||
plugins: [recentSearchesPlugin],
|
||||
openOnFocus: true,
|
||||
placeholder: trans(SEARCH_PLACEHOLDER),
|
||||
placeholder: trans("search.placeholder"),
|
||||
translations: {
|
||||
submitButtonTitle: trans(SEARCH_SUBMIT)
|
||||
submitButtonTitle: trans("search.submit")
|
||||
},
|
||||
|
||||
// Use a navigator compatible with turbo:
|
||||
@@ -153,7 +150,7 @@ export default class extends Controller {
|
||||
},
|
||||
templates: {
|
||||
header({ html }) {
|
||||
return html`<span class="aa-SourceHeaderTitle">${trans(STATISTICS_PARTS)}</span>
|
||||
return html`<span class="aa-SourceHeaderTitle">${trans("part.labelp")}</span>
|
||||
<div class="aa-SourceHeaderLine" />`;
|
||||
},
|
||||
item({item, components, html}) {
|
||||
@@ -197,4 +194,4 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default class extends Controller {
|
||||
|
||||
let settings = {
|
||||
allowEmptyOption: true,
|
||||
plugins: ['dropdown_input'],
|
||||
plugins: ['dropdown_input', this.element.required ? null : 'clear_button'],
|
||||
searchField: ["name", "description", "category", "footprint"],
|
||||
valueField: "id",
|
||||
labelField: "name",
|
||||
|
||||
@@ -25,8 +25,7 @@ import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en';
|
||||
import * as zxcvbnDePackage from '@zxcvbn-ts/language-de';
|
||||
import * as zxcvbnFrPackage from '@zxcvbn-ts/language-fr';
|
||||
import * as zxcvbnJaPackage from '@zxcvbn-ts/language-ja';
|
||||
import {trans, USER_PASSWORD_STRENGTH_VERY_WEAK, USER_PASSWORD_STRENGTH_WEAK, USER_PASSWORD_STRENGTH_MEDIUM,
|
||||
USER_PASSWORD_STRENGTH_STRONG, USER_PASSWORD_STRENGTH_VERY_STRONG} from '../../translator.js';
|
||||
import {trans} from '../../translator.js';
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
@@ -89,23 +88,23 @@ export default class extends Controller {
|
||||
|
||||
switch (level) {
|
||||
case 0:
|
||||
text = trans(USER_PASSWORD_STRENGTH_VERY_WEAK);
|
||||
text = trans("user.password_strength.very_weak");
|
||||
classes = "bg-danger badge-danger";
|
||||
break;
|
||||
case 1:
|
||||
text = trans(USER_PASSWORD_STRENGTH_WEAK);
|
||||
text = trans("user.password_strength.weak");
|
||||
classes = "bg-warning badge-warning";
|
||||
break;
|
||||
case 2:
|
||||
text = trans(USER_PASSWORD_STRENGTH_MEDIUM)
|
||||
text = trans("user.password_strength.medium");
|
||||
classes = "bg-info badge-info";
|
||||
break;
|
||||
case 3:
|
||||
text = trans(USER_PASSWORD_STRENGTH_STRONG);
|
||||
text = trans("user.password_strength.strong");
|
||||
classes = "bg-primary badge-primary";
|
||||
break;
|
||||
case 4:
|
||||
text = trans(USER_PASSWORD_STRENGTH_VERY_STRONG);
|
||||
text = trans("user.password_strength.very_strong");
|
||||
classes = "bg-success badge-success";
|
||||
break;
|
||||
default:
|
||||
@@ -120,4 +119,4 @@ export default class extends Controller {
|
||||
this.badgeTarget.classList.add("badge");
|
||||
this.badgeTarget.classList.add(...classes.split(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import '../../css/components/tom-select_extensions.css';
|
||||
import TomSelect from "tom-select";
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js'
|
||||
import {trans} from '../../translator.js'
|
||||
|
||||
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||
@@ -204,7 +204,7 @@ export default class extends Controller {
|
||||
|
||||
if (data.not_in_db_yet) {
|
||||
//Not yet added items are shown italic and with a badge
|
||||
name += "<i><b>" + escape(data.text) + "</b></i>" + "<span class='ms-3 badge bg-info badge-info'>" + trans(ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB) + "</span>";
|
||||
name += "<i><b>" + escape(data.text) + "</b></i>" + "<span class='ms-3 badge bg-info badge-info'>" + trans("entity.select.group.new_not_added_to_DB") + "</span>";
|
||||
} else {
|
||||
name += "<b>" + escape(data.text) + "</b>";
|
||||
}
|
||||
|
||||
@@ -62,6 +62,6 @@ export default class extends Controller {
|
||||
element.disabled = true;
|
||||
}
|
||||
|
||||
form.submit();
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,6 @@ export default class extends Controller {
|
||||
//Put our decoded Text into the input box
|
||||
document.getElementById('scan_dialog_input').value = decodedText;
|
||||
//Submit form
|
||||
document.getElementById('scan_dialog_form').submit();
|
||||
document.getElementById('scan_dialog_form').requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
assets/controllers/pages/part_stocktake_modal_controller.js
Normal file
27
assets/controllers/pages/part_stocktake_modal_controller.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
import {Modal} from "bootstrap";
|
||||
|
||||
export default class extends Controller
|
||||
{
|
||||
connect() {
|
||||
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
|
||||
}
|
||||
|
||||
_handleModalOpen(event) {
|
||||
// Button that triggered the modal
|
||||
const button = event.relatedTarget;
|
||||
|
||||
const amountInput = this.element.querySelector('input[name="amount"]');
|
||||
|
||||
// Extract info from button attributes
|
||||
const lotID = button.getAttribute('data-lot-id');
|
||||
const lotAmount = button.getAttribute('data-lot-amount');
|
||||
|
||||
//Find the expected amount field and set the value to the lot amount
|
||||
const expectedAmountInput = this.element.querySelector('#stocktake-modal-expected-amount');
|
||||
expectedAmountInput.textContent = lotAmount;
|
||||
|
||||
//Set the action and lotID inputs in the form
|
||||
this.element.querySelector('input[name="lot_id"]').setAttribute('value', lotID);
|
||||
}
|
||||
}
|
||||
81
assets/controllers/update_confirm_controller.js
Normal file
81
assets/controllers/update_confirm_controller.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/**
|
||||
* Stimulus controller for update/downgrade confirmation dialogs.
|
||||
* Intercepts form submission and shows a confirmation dialog before proceeding.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
isDowngrade: { type: Boolean, default: false },
|
||||
targetVersion: { type: String, default: '' },
|
||||
confirmUpdate: { type: String, default: 'Are you sure you want to update Part-DB?' },
|
||||
confirmDowngrade: { type: String, default: 'Are you sure you want to downgrade Part-DB?' },
|
||||
downgradeWarning: { type: String, default: 'WARNING: This version does not include the Update Manager.' },
|
||||
minUpdateManagerVersion: { type: String, default: '2.6.0' },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.element.addEventListener('submit', this.handleSubmit.bind(this));
|
||||
}
|
||||
|
||||
handleSubmit(event) {
|
||||
// Always prevent default first
|
||||
event.preventDefault();
|
||||
|
||||
const targetClean = this.targetVersionValue.replace(/^v/, '');
|
||||
let message;
|
||||
|
||||
if (this.isDowngradeValue) {
|
||||
// Check if downgrading to a version without Update Manager
|
||||
if (this.compareVersions(targetClean, this.minUpdateManagerVersionValue) < 0) {
|
||||
message = this.confirmDowngradeValue + '\n\n⚠️ ' + this.downgradeWarningValue;
|
||||
} else {
|
||||
message = this.confirmDowngradeValue;
|
||||
}
|
||||
} else {
|
||||
message = this.confirmUpdateValue;
|
||||
}
|
||||
|
||||
// Only submit if user confirms
|
||||
if (confirm(message)) {
|
||||
// Remove the event listener to prevent infinite loop, then submit
|
||||
this.element.removeEventListener('submit', this.handleSubmit.bind(this));
|
||||
this.element.submit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two version strings (e.g., "2.5.0" vs "2.6.0")
|
||||
* Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
*/
|
||||
compareVersions(v1, v2) {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const p1 = parts1[i] || 0;
|
||||
const p2 = parts2[i] || 0;
|
||||
if (p1 < p2) return -1;
|
||||
if (p1 > p2) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -125,3 +125,25 @@ Classes for Datatables export
|
||||
.export-helper{
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**********************************************************
|
||||
* Table row highlighting tools
|
||||
***********************************************************/
|
||||
|
||||
.row-highlight {
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.20); /* Adds depth */
|
||||
position: relative;
|
||||
z-index: 1; /* Ensures the shadow overlaps other rows */
|
||||
border-left: 5px solid var(--bs-primary); /* Adds a vertical accent bar */
|
||||
}
|
||||
|
||||
@keyframes pulse-highlight {
|
||||
0% { outline: 2px solid transparent; }
|
||||
50% { outline: 2px solid var(--bs-primary); }
|
||||
100% { outline: 2px solid transparent; }
|
||||
}
|
||||
|
||||
.row-pulse {
|
||||
animation: pulse-highlight 1s ease-in-out;
|
||||
animation-iteration-count: 3;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ import "./register_events";
|
||||
import "./tristate_checkboxes";
|
||||
|
||||
//Define jquery globally
|
||||
window.$ = window.jQuery = require("jquery");
|
||||
global.$ = global.jQuery = require("jquery");
|
||||
|
||||
//Use the local WASM file for the ZXing library
|
||||
import {
|
||||
|
||||
@@ -56,7 +56,8 @@ class TristateHelper {
|
||||
|
||||
document.addEventListener("turbo:load", listener);
|
||||
document.addEventListener("turbo:render", listener);
|
||||
document.addEventListener("collection:elementAdded", listener);
|
||||
}
|
||||
}
|
||||
|
||||
export default new TristateHelper();
|
||||
export default new TristateHelper();
|
||||
|
||||
@@ -198,6 +198,7 @@ class WebauthnTFA {
|
||||
{
|
||||
const resultField = document.getElementById('_auth_code');
|
||||
resultField.value = JSON.stringify(data)
|
||||
//requestSubmit() do not work here, probably because the submit is considered invalid. But as we do not use CSFR tokens, it should be fine.
|
||||
form.submit();
|
||||
}
|
||||
|
||||
@@ -232,4 +233,4 @@ class WebauthnTFA {
|
||||
}
|
||||
}
|
||||
|
||||
window.webauthnTFA = new WebauthnTFA();
|
||||
window.webauthnTFA = new WebauthnTFA();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { localeFallbacks } from '../var/translations/configuration';
|
||||
import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-translator';
|
||||
import { createTranslator } from '@symfony/ux-translator';
|
||||
import { messages, localeFallbacks } from '../var/translations/index.js';
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony UX Translator package.
|
||||
*
|
||||
@@ -9,8 +10,12 @@ import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-tra
|
||||
* If you use TypeScript, you can rename this file to "translator.ts" to take advantage of types checking.
|
||||
*/
|
||||
|
||||
setLocaleFallbacks(localeFallbacks);
|
||||
const translator = createTranslator({
|
||||
messages,
|
||||
localeFallbacks,
|
||||
});
|
||||
|
||||
export { trans };
|
||||
|
||||
export * from '../var/translations';
|
||||
// Wrapper function with default domain set to 'frontend'
|
||||
export const trans = (id, parameters = {}, domain = 'frontend', locale = null) => {
|
||||
return translator.trans(id, parameters, domain, locale);
|
||||
};
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-zip": "*",
|
||||
"amphp/http-client": "^5.1",
|
||||
"api-platform/doctrine-orm": "^4.1",
|
||||
"api-platform/json-api": "^4.0.0",
|
||||
"api-platform/symfony": "^4.0.0",
|
||||
"beberlei/doctrineextensions": "^1.2",
|
||||
"brick/math": "^0.13.1",
|
||||
"brick/schema": "^0.2.0",
|
||||
"composer/ca-bundle": "^1.5",
|
||||
"composer/package-versions-deprecated": "^1.11.99.5",
|
||||
"doctrine/data-fixtures": "^2.0.0",
|
||||
@@ -79,7 +81,8 @@
|
||||
"symfony/string": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/ux-translator": "^2.10",
|
||||
"symfony/type-info": "7.4.*",
|
||||
"symfony/ux-translator": "^2.32.0",
|
||||
"symfony/ux-turbo": "^2.0",
|
||||
"symfony/validator": "7.4.*",
|
||||
"symfony/web-link": "7.4.*",
|
||||
@@ -87,6 +90,7 @@
|
||||
"symfony/yaml": "7.4.*",
|
||||
"symplify/easy-coding-standard": "^12.5.20",
|
||||
"tecnickcom/tc-lib-barcode": "^2.1.4",
|
||||
"tiendanube/gtinvalidation": "^1.0",
|
||||
"twig/cssinliner-extra": "^3.0",
|
||||
"twig/extra-bundle": "^3.8",
|
||||
"twig/html-extra": "^3.8",
|
||||
|
||||
2241
composer.lock
generated
2241
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -23,3 +23,7 @@ framework:
|
||||
|
||||
info_provider.cache:
|
||||
adapter: cache.app
|
||||
|
||||
cache.settings:
|
||||
adapter: cache.app
|
||||
tags: true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# yaml-language-server: $schema=../../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
@@ -8,6 +9,7 @@ framework:
|
||||
# Must be set to true, to enable the change of HTTP method via _method parameter, otherwise our delete routines does not work anymore
|
||||
# TODO: Rework delete routines to work without _method parameter as it is not recommended anymore (see https://github.com/symfony/symfony/issues/45278)
|
||||
http_method_override: true
|
||||
allowed_http_method_override: ['DELETE']
|
||||
|
||||
# Allow users to configure trusted hosts via .env variables
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html#trusted-hosts
|
||||
|
||||
@@ -35,4 +35,4 @@ knpu_oauth2_client:
|
||||
provider_options:
|
||||
urlAuthorize: 'https://identity.nexar.com/connect/authorize'
|
||||
urlAccessToken: 'https://identity.nexar.com/connect/token'
|
||||
urlResourceOwnerDetails: ''
|
||||
urlResourceOwnerDetails: ''
|
||||
|
||||
@@ -3,6 +3,7 @@ jbtronics_settings:
|
||||
|
||||
cache:
|
||||
default_cacheable: true
|
||||
service: 'cache.settings'
|
||||
|
||||
orm_storage:
|
||||
default_entity_class: App\Entity\SettingsEntry
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
framework:
|
||||
default_locale: 'en'
|
||||
# Just enable the locales we need for performance reasons.
|
||||
enabled_locale: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl']
|
||||
enabled_locale: '%partdb.locale_menu%'
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
fallbacks:
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
ux_translator:
|
||||
# The directory where the JavaScript translations are dumped
|
||||
dump_directory: '%kernel.project_dir%/var/translations'
|
||||
# Only include the frontend translation domain in the JavaScript bundle
|
||||
domains:
|
||||
- 'frontend'
|
||||
|
||||
when@prod:
|
||||
ux_translator:
|
||||
# Control whether TypeScript types are dumped alongside translations.
|
||||
# Disable this if you do not use TypeScript (e.g. in production when using AssetMapper), to speed up cache warmup.
|
||||
# dump_typescript: false
|
||||
|
||||
@@ -68,6 +68,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
||||
move:
|
||||
label: "perm.parts_stock.move"
|
||||
apiTokenRole: ROLE_API_EDIT
|
||||
stocktake:
|
||||
label: "perm.parts_stock.stocktake"
|
||||
apiTokenRole: ROLE_API_EDIT
|
||||
|
||||
|
||||
storelocations: &PART_CONTAINING
|
||||
@@ -297,6 +300,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
||||
show_updates:
|
||||
label: "perm.system.show_available_updates"
|
||||
apiTokenRole: ROLE_API_ADMIN
|
||||
manage_updates:
|
||||
label: "perm.system.manage_updates"
|
||||
alsoSet: ['show_updates', 'server_infos']
|
||||
apiTokenRole: ROLE_API_ADMIN
|
||||
|
||||
|
||||
attachments:
|
||||
|
||||
3282
config/reference.php
3282
config/reference.php
File diff suppressed because it is too large
Load Diff
@@ -5,3 +5,5 @@ files:
|
||||
translation: /translations/validators.%two_letters_code%.xlf
|
||||
- source: /translations/security.en.xlf
|
||||
translation: /translations/security.%two_letters_code%.xlf
|
||||
- source: /translations/frontend.en.xlf
|
||||
translation: /translations/frontend.%two_letters_code%.xlf
|
||||
|
||||
@@ -21,8 +21,8 @@ differences between them, which might be important for you. Therefore the pros a
|
||||
are listed here.
|
||||
|
||||
{: .important }
|
||||
You have to choose between the database types before you start using Part-DB and **you can not change it (easily) after
|
||||
you have started creating data**. So you should choose the database type for your use case (and possible future uses).
|
||||
While you can change the database platform later (see below), it is still experimental and not recommended.
|
||||
So you should choose the database type for your use case (and possible future uses).
|
||||
|
||||
## Comparison
|
||||
|
||||
@@ -180,3 +180,23 @@ and it is automatically used if available.
|
||||
For SQLite and MySQL < 10.7 it has to be emulated if wanted, which is pretty slow. Therefore it has to be explicitly enabled by setting the
|
||||
`DATABASE_EMULATE_NATURAL_SORT` environment variable to `1`. If it is 0 the classical binary sorting is used, on these databases. The emulations
|
||||
might have some quirks and issues, so it is recommended to use a database which supports natural sorting natively, if you want to use it.
|
||||
|
||||
## Converting between database platforms
|
||||
|
||||
{: .important }
|
||||
The database conversion is still experimental. Therefore it is recommended to backup your database before performing a conversion, and check if everything works as expected afterwards.
|
||||
|
||||
If you want to change the database platform of your Part-DB installation (e.g. from SQLite to MySQL/MariaDB or PostgreSQL, or vice versa), there is the `partdb:migrations:convert-db-platform` console command, which can help you with that:
|
||||
|
||||
1. Make a backup of your current database to be safe if something goes wrong (see the backup documentation).
|
||||
2. Ensure that your database is at the latest schema by running the migrations: `php bin/console doctrine:migrations:migrate`
|
||||
3. Change the `DATABASE_URL` environment variable to the new database platform and connection information. Copy the old `DATABASE_URL` as you will need it later.
|
||||
4. Run `php bin/console doctrine:migrations:migrate` again to create the database schema in the new database. You will not need the admin password, that is shown when running the migrations.
|
||||
5. Run the conversion command, where you have to provide the old `DATABASE_URL` as parameter: `php bin/console partdb:migrations:convert-db-platform <OLD_DATABASE_URL>`
|
||||
Replace `<OLD_DATABASE_URL` with the actual old `DATABASE_URL` value (e.g. `sqlite:///%kernel.project_dir%/var/app.db`):
|
||||
```bash
|
||||
php bin/console partdb:migrations:convert-db-platform sqlite:///%kernel.project_dir%/var/app.db
|
||||
```
|
||||
6. The command will purge all data in the new database and copy all data from the old database to the new one. This might take some time and memory depending on the size of your database.
|
||||
7. Clear the cache: `php bin/console partdb:cache:clear`
|
||||
8. You can login with your existing user accounts in the new database now. Check if everything works as expected.
|
||||
|
||||
BIN
docs/screenshots/update-manager-interface.png
Normal file
BIN
docs/screenshots/update-manager-interface.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
@@ -50,6 +50,14 @@ docker exec --user=www-data partdb php bin/console cache:clear
|
||||
* `php bin/console partdb:currencies:update-exchange-rates`: Update the exchange rates of all currencies from the
|
||||
internet
|
||||
|
||||
## Update Manager commands
|
||||
|
||||
{: .note }
|
||||
> The Update Manager is an experimental feature. See the [Update Manager documentation](update_manager.md) for details.
|
||||
|
||||
* `php bin/console partdb:update`: Check for and perform updates to Part-DB. Use `--check` to only check for updates without installing.
|
||||
* `php bin/console partdb:maintenance-mode`: Enable, disable, or check the status of maintenance mode. Use `--enable`, `--disable`, or `--status`.
|
||||
|
||||
## Installation/Maintenance commands
|
||||
|
||||
* `php bin/console partdb:backup`: Backup the database and the attachments
|
||||
@@ -68,6 +76,7 @@ docker exec --user=www-data partdb php bin/console cache:clear
|
||||
deleted!*
|
||||
* `settings:migrate-env-to-settings`: Migrate configuration from environment variables to the settings interface.
|
||||
The value of the environment variable is copied to the settings database, so the environment variable can be removed afterwards without losing the configuration.
|
||||
* `partdb:migrations:convert-db-platform`: Convert the database platform (e.g. from SQLite to MySQL/MariaDB or PostgreSQL, or vice versa).
|
||||
|
||||
## Database commands
|
||||
|
||||
|
||||
@@ -96,6 +96,21 @@ The following providers are currently available and shipped with Part-DB:
|
||||
|
||||
(All trademarks are property of their respective owners. Part-DB is not affiliated with any of the companies.)
|
||||
|
||||
### Generic Web URL Provider
|
||||
The Generic Web URL Provider can extract part information from any webpage that contains structured data in the form of
|
||||
[Schema.org](https://schema.org/) format. Many e-commerce websites use this format to provide detailed product information
|
||||
for search engines and other services. Therefore it allows Part-DB to retrieve rudimentary part information (like name, image and price)
|
||||
from a wide range of websites without the need for a dedicated API integration.
|
||||
To use the Generic Web URL Provider, simply enable it in the information provider settings. No additional configuration
|
||||
is required. Afterwards you can enter any product URL in the search field, and Part-DB will attempt to extract the relevant part information
|
||||
from the webpage.
|
||||
|
||||
Please note that if this provider is enabled, Part-DB will make HTTP requests to external websites to fetch product data, which
|
||||
may have privacy and security implications.
|
||||
|
||||
Following env configuration options are available:
|
||||
* `PROVIDER_GENERIC_WEB_ENABLED`: Set this to `1` to enable the Generic Web URL Provider (optional, default: `0`)
|
||||
|
||||
### Octopart
|
||||
|
||||
The Octopart provider uses the [Octopart / Nexar API](https://nexar.com/api) to search for parts and get information.
|
||||
@@ -260,6 +275,34 @@ This is not an official API and could break at any time. So use it at your own r
|
||||
The following env configuration options are available:
|
||||
* `PROVIDER_POLLIN_ENABLED`: Set this to `1` to enable the Pollin provider
|
||||
|
||||
### Buerklin
|
||||
|
||||
The Buerklin provider uses the [Buerklin API](https://www.buerklin.com/en/services/eprocurement/) to search for parts and get information.
|
||||
To use it you have to request access to the API.
|
||||
You will get an e-mail with the client ID and client secret, which you have to put in the Part-DB configuration (see below).
|
||||
|
||||
Please note that the Buerklin API is limited to 100 requests/minute per IP address and
|
||||
access to the Authentication server is limited to 10 requests/minute per IP address
|
||||
|
||||
The following env configuration options are available:
|
||||
|
||||
* `PROVIDER_BUERKLIN_CLIENT_ID`: The client ID you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_SECRET`: The client secret you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_USERNAME`: The username you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_PASSWORD`: The password you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_CURRENCY`: The currency you want to get prices in if available (optional, 3 letter ISO-code, default: `EUR`).
|
||||
* `PROVIDER_BUERKLIN_LANGUAGE`: The language you want to get the descriptions in. Possible values: `de` = German, `en` = English. (optional, default: `en`)
|
||||
|
||||
### Conrad
|
||||
|
||||
The conrad provider the [Conrad API](https://developer.conrad.com/) to search for parts and retried their information.
|
||||
To use it you have to request access to the API, however it seems currently your mail address needs to be allowlisted before you can register for an account.
|
||||
The conrad webpages uses the API key in the requests, so you might be able to extract a working API key by listening to browser requests.
|
||||
That method is not officially supported nor encouraged by Part-DB, and might break at any moment.
|
||||
|
||||
The following env configuration options are available:
|
||||
* `PROVIDER_CONRAD_API_KEY`: The API key you got from Conrad (mandatory)
|
||||
|
||||
### Custom provider
|
||||
|
||||
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
|
||||
|
||||
170
docs/usage/update_manager.md
Normal file
170
docs/usage/update_manager.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
title: Update Manager
|
||||
layout: default
|
||||
parent: Usage
|
||||
---
|
||||
|
||||
# Update Manager (Experimental)
|
||||
|
||||
{: .warning }
|
||||
> The Update Manager is currently an **experimental feature**. It is disabled by default while user experience data is being gathered. Use with caution and always ensure you have proper backups before updating.
|
||||
|
||||
Part-DB includes an Update Manager that can automatically update Git-based installations to newer versions. The Update Manager provides both a web interface and CLI commands for managing updates, backups, and maintenance mode.
|
||||
|
||||
## Supported Installation Types
|
||||
|
||||
The Update Manager currently supports automatic updates only for **Git clone** installations. Other installation types show manual update instructions:
|
||||
|
||||
| Installation Type | Auto-Update | Instructions |
|
||||
|-------------------|-------------|--------------|
|
||||
| Git Clone | Yes | Automatic via CLI or Web UI |
|
||||
| Docker | No | Pull new image: `docker-compose pull && docker-compose up -d` |
|
||||
| ZIP Release | No | Download and extract new release manually |
|
||||
|
||||
## Enabling the Update Manager
|
||||
|
||||
By default, web-based updates and backup restore are **disabled** for security reasons. To enable them, add these settings to your `.env.local` file:
|
||||
|
||||
```bash
|
||||
# Enable web-based updates (default: disabled)
|
||||
DISABLE_WEB_UPDATES=0
|
||||
|
||||
# Enable backup restore via web interface (default: disabled)
|
||||
DISABLE_BACKUP_RESTORE=0
|
||||
```
|
||||
|
||||
{: .note }
|
||||
> Even with web updates disabled, you can still use the CLI commands to perform updates.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Update Command
|
||||
|
||||
Check for updates or perform an update:
|
||||
|
||||
```bash
|
||||
# Check for available updates
|
||||
php bin/console partdb:update --check
|
||||
|
||||
# Update to the latest version
|
||||
php bin/console partdb:update
|
||||
|
||||
# Update to a specific version
|
||||
php bin/console partdb:update v2.6.0
|
||||
|
||||
# Update without creating a backup first
|
||||
php bin/console partdb:update --no-backup
|
||||
|
||||
# Force update without confirmation prompt
|
||||
php bin/console partdb:update --force
|
||||
```
|
||||
|
||||
### Maintenance Mode Command
|
||||
|
||||
Manually enable or disable maintenance mode:
|
||||
|
||||
```bash
|
||||
# Enable maintenance mode with default message
|
||||
php bin/console partdb:maintenance-mode --enable
|
||||
|
||||
# Enable with custom message
|
||||
php bin/console partdb:maintenance-mode --enable "System maintenance until 6 PM"
|
||||
php bin/console partdb:maintenance-mode --enable --message="Updating to v2.6.0"
|
||||
|
||||
# Disable maintenance mode
|
||||
php bin/console partdb:maintenance-mode --disable
|
||||
|
||||
# Check current status
|
||||
php bin/console partdb:maintenance-mode --status
|
||||
```
|
||||
|
||||
## Web Interface
|
||||
|
||||
When web updates are enabled, the Update Manager is accessible at **System > Update Manager** (URL: `/system/update-manager`).
|
||||
|
||||
The web interface shows:
|
||||
- Current version and installation type
|
||||
- Available updates with release notes
|
||||
- Precondition validation (Git, Composer, Yarn, permissions)
|
||||
- Update history and logs
|
||||
- Backup management
|
||||
|
||||
### Required Permissions
|
||||
|
||||
Users need the following permissions to access the Update Manager:
|
||||
|
||||
| Permission | Description |
|
||||
|------------|-------------|
|
||||
| `@system.show_updates` | View update status and available versions |
|
||||
| `@system.manage_updates` | Perform updates and restore backups |
|
||||
|
||||
## Update Process
|
||||
|
||||
When an update is performed, the following steps are executed:
|
||||
|
||||
1. **Lock** - Acquire exclusive lock to prevent concurrent updates
|
||||
2. **Maintenance Mode** - Enable maintenance mode to block user access
|
||||
3. **Rollback Tag** - Create a Git tag for potential rollback
|
||||
4. **Backup** - Create a full backup (optional but recommended)
|
||||
5. **Git Fetch** - Fetch latest changes from origin
|
||||
6. **Git Checkout** - Checkout the target version
|
||||
7. **Composer Install** - Install/update PHP dependencies
|
||||
8. **Yarn Install** - Install frontend dependencies
|
||||
9. **Yarn Build** - Compile frontend assets
|
||||
10. **Database Migrations** - Run any new migrations
|
||||
11. **Cache Clear** - Clear the application cache
|
||||
12. **Cache Warmup** - Rebuild the cache
|
||||
13. **Maintenance Off** - Disable maintenance mode
|
||||
14. **Unlock** - Release the update lock
|
||||
|
||||
If any step fails, the system automatically attempts to rollback to the previous version.
|
||||
|
||||
## Backup Management
|
||||
|
||||
The Update Manager automatically creates backups before updates. These backups are stored in `var/backups/` and include:
|
||||
|
||||
- Database dump (SQL file or SQLite database)
|
||||
- Configuration files (`.env.local`, `parameters.yaml`, `banner.md`)
|
||||
- Attachment files (`uploads/`, `public/media/`)
|
||||
|
||||
### Restoring from Backup
|
||||
|
||||
{: .warning }
|
||||
> Backup restore is a destructive operation that will overwrite your current database. Only use this if you need to recover from a failed update.
|
||||
|
||||
If web restore is enabled (`DISABLE_BACKUP_RESTORE=0`), you can restore backups from the web interface. The restore process:
|
||||
|
||||
1. Enables maintenance mode
|
||||
2. Extracts the backup
|
||||
3. Restores the database
|
||||
4. Optionally restores config and attachments
|
||||
5. Clears and warms up the cache
|
||||
6. Disables maintenance mode
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Precondition Errors
|
||||
|
||||
Before updating, the system validates:
|
||||
|
||||
- **Git available**: Git must be installed and in PATH
|
||||
- **No local changes**: Uncommitted changes must be committed or stashed
|
||||
- **Composer available**: Composer must be installed and in PATH
|
||||
- **Yarn available**: Yarn must be installed and in PATH
|
||||
- **Write permissions**: `var/`, `vendor/`, and `public/` must be writable
|
||||
- **Not already locked**: No other update can be in progress
|
||||
|
||||
### Stale Lock
|
||||
|
||||
If an update was interrupted and the lock file remains, it will automatically be removed after 1 hour. You can also manually delete `var/update.lock`.
|
||||
|
||||
### Viewing Update Logs
|
||||
|
||||
Update logs are stored in `var/log/updates/` and can be viewed from the web interface or directly on the server.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Disable web updates in production** unless you specifically need them
|
||||
- The Update Manager requires shell access to run Git, Composer, and Yarn
|
||||
- Backup files may contain sensitive data (database, config) - secure the `var/backups/` directory
|
||||
- Consider running updates during maintenance windows with low user activity
|
||||
91
makefile
91
makefile
@@ -1,91 +0,0 @@
|
||||
# PartDB Makefile for Test Environment Management
|
||||
|
||||
.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \
|
||||
test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \
|
||||
section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset
|
||||
|
||||
# Default target
|
||||
help: ## Show this help
|
||||
@awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
# Dependencies
|
||||
deps-install: ## Install PHP dependencies with unlimited memory
|
||||
@echo "📦 Installing PHP dependencies..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 composer install
|
||||
yarn install
|
||||
@echo "✅ Dependencies installed"
|
||||
|
||||
# Complete test environment setup
|
||||
test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures)
|
||||
@echo "✅ Test environment setup complete!"
|
||||
|
||||
# Clean test environment
|
||||
test-clean: ## Clean test cache and database files
|
||||
@echo "🧹 Cleaning test environment..."
|
||||
rm -rf var/cache/test
|
||||
rm -f var/app_test.db
|
||||
@echo "✅ Test environment cleaned"
|
||||
|
||||
# Create test database
|
||||
test-db-create: ## Create test database (if not exists)
|
||||
@echo "🗄️ Creating test database..."
|
||||
-php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
|
||||
|
||||
# Run database migrations for test environment
|
||||
test-db-migrate: ## Run database migrations for test environment
|
||||
@echo "🔄 Running database migrations..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test
|
||||
|
||||
# Clear test cache
|
||||
test-cache-clear: ## Clear test cache
|
||||
@echo "🗑️ Clearing test cache..."
|
||||
rm -rf var/cache/test
|
||||
@echo "✅ Test cache cleared"
|
||||
|
||||
# Load test fixtures
|
||||
test-fixtures: ## Load test fixtures
|
||||
@echo "📦 Loading test fixtures..."
|
||||
php bin/console partdb:fixtures:load -n --env test
|
||||
|
||||
# Run PHPUnit tests
|
||||
test-run: ## Run PHPUnit tests
|
||||
@echo "🧪 Running tests..."
|
||||
php bin/phpunit
|
||||
|
||||
# Quick test reset (clean + migrate + fixtures, skip DB creation)
|
||||
test-reset: test-cache-clear test-db-migrate test-fixtures
|
||||
@echo "✅ Test environment reset complete!"
|
||||
|
||||
test-typecheck: ## Run static analysis (PHPStan)
|
||||
@echo "🧪 Running type checks..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 composer phpstan
|
||||
|
||||
# Development helpers
|
||||
dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup)
|
||||
@echo "✅ Development environment setup complete!"
|
||||
|
||||
dev-clean: ## Clean development cache and database files
|
||||
@echo "🧹 Cleaning development environment..."
|
||||
rm -rf var/cache/dev
|
||||
rm -f var/app_dev.db
|
||||
@echo "✅ Development environment cleaned"
|
||||
|
||||
dev-db-create: ## Create development database (if not exists)
|
||||
@echo "🗄️ Creating development database..."
|
||||
-php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
|
||||
|
||||
dev-db-migrate: ## Run database migrations for development environment
|
||||
@echo "🔄 Running database migrations..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev
|
||||
|
||||
dev-cache-clear: ## Clear development cache
|
||||
@echo "🗑️ Clearing development cache..."
|
||||
rm -rf var/cache/dev
|
||||
@echo "✅ Development cache cleared"
|
||||
|
||||
dev-warmup: ## Warm up development cache
|
||||
@echo "🔥 Warming up development cache..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n
|
||||
|
||||
dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate)
|
||||
@echo "✅ Development environment reset complete!"
|
||||
129
migrations/Version20260208131116.php
Normal file
129
migrations/Version20260208131116.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Migration\AbstractMultiPlatformMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
|
||||
final class Version20260208131116 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add GTIN fields, allowed targets for attachment types and last stocktake date for part lots and add include_vat field for price details.';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE attachment_types ADD allowed_targets LONGTEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE part_lots ADD last_stocktake_at DATETIME DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat TINYINT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE `attachment_types` DROP allowed_targets');
|
||||
$this->addSql('DROP INDEX parts_idx_gtin ON `parts`');
|
||||
$this->addSql('ALTER TABLE `parts` DROP gtin');
|
||||
$this->addSql('ALTER TABLE part_lots DROP last_stocktake_at');
|
||||
$this->addSql('ALTER TABLE `orderdetails` DROP prices_includes_vat');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE attachment_types ADD COLUMN allowed_targets CLOB DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE part_lots ADD COLUMN last_stocktake_at DATETIME DEFAULT NULL');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM parts');
|
||||
$this->addSql('DROP TABLE parts');
|
||||
$this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, ipn VARCHAR(100) DEFAULT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, gtin VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES part_custom_states (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO parts (id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint) SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM __temp__parts');
|
||||
$this->addSql('DROP TABLE __temp__parts');
|
||||
$this->addSql('CREATE INDEX parts_idx_name ON parts (name)');
|
||||
$this->addSql('CREATE INDEX parts_idx_ipn ON parts (ipn)');
|
||||
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON parts (datetime_added, name, last_modified, id, needs_review)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state)');
|
||||
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD COLUMN prices_includes_vat BOOLEAN DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__attachment_types AS SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM "attachment_types"');
|
||||
$this->addSql('DROP TABLE "attachment_types"');
|
||||
$this->addSql('CREATE TABLE "attachment_types" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, alternative_names CLOB DEFAULT NULL, filetype_filter CLOB NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, CONSTRAINT FK_EFAED719727ACA70 FOREIGN KEY (parent_id) REFERENCES "attachment_types" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EFAED719EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO "attachment_types" (id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment) SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM __temp__attachment_types');
|
||||
$this->addSql('DROP TABLE __temp__attachment_types');
|
||||
$this->addSql('CREATE INDEX IDX_EFAED719727ACA70 ON "attachment_types" (parent_id)');
|
||||
$this->addSql('CREATE INDEX IDX_EFAED719EA7100A1 ON "attachment_types" (id_preview_attachment)');
|
||||
$this->addSql('CREATE INDEX attachment_types_idx_name ON "attachment_types" (name)');
|
||||
$this->addSql('CREATE INDEX attachment_types_idx_parent_name ON "attachment_types" (parent_id, name)');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner FROM part_lots');
|
||||
$this->addSql('DROP TABLE part_lots');
|
||||
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO part_lots (id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner) SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner FROM __temp__part_lots');
|
||||
$this->addSql('DROP TABLE __temp__part_lots');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM "parts"');
|
||||
$this->addSql('DROP TABLE "parts"');
|
||||
$this->addSql('CREATE TABLE "parts" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, ipn VARCHAR(100) DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES "orderdetails" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO "parts" (id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id) SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM __temp__parts');
|
||||
$this->addSql('DROP TABLE __temp__parts');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON "parts" (id_preview_attachment)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON "parts" (id_part_custom_state)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON "parts" (id_category)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON "parts" (id_footprint)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON "parts" (id_part_unit)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON "parts" (id_manufacturer)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON "parts" (order_orderdetails_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON "parts" (built_project_id)');
|
||||
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review)');
|
||||
$this->addSql('CREATE INDEX parts_idx_name ON "parts" (name)');
|
||||
$this->addSql('CREATE INDEX parts_idx_ipn ON "parts" (ipn)');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__orderdetails AS SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM "orderdetails"');
|
||||
$this->addSql('DROP TABLE "orderdetails"');
|
||||
$this->addSql('CREATE TABLE "orderdetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, supplierpartnr VARCHAR(255) NOT NULL, obsolete BOOLEAN NOT NULL, supplier_product_url CLOB NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, part_id INTEGER NOT NULL, id_supplier INTEGER DEFAULT NULL, CONSTRAINT FK_489AFCDC4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_489AFCDCCBF180EB FOREIGN KEY (id_supplier) REFERENCES "suppliers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO "orderdetails" (id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier) SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM __temp__orderdetails');
|
||||
$this->addSql('DROP TABLE __temp__orderdetails');
|
||||
$this->addSql('CREATE INDEX IDX_489AFCDC4CE34BEC ON "orderdetails" (part_id)');
|
||||
$this->addSql('CREATE INDEX IDX_489AFCDCCBF180EB ON "orderdetails" (id_supplier)');
|
||||
$this->addSql('CREATE INDEX orderdetails_supplier_part_nr ON "orderdetails" (supplierpartnr)');
|
||||
}
|
||||
|
||||
public function postgreSQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE attachment_types ADD allowed_targets TEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE part_lots ADD last_stocktake_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat BOOLEAN DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function postgreSQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "attachment_types" DROP allowed_targets');
|
||||
$this->addSql('ALTER TABLE part_lots DROP last_stocktake_at');
|
||||
$this->addSql('DROP INDEX parts_idx_gtin');
|
||||
$this->addSql('ALTER TABLE "parts" DROP gtin');
|
||||
$this->addSql('ALTER TABLE "orderdetails" DROP prices_includes_vat');
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
"popper.js": "^1.14.7",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-bundle-analyzer": "^4.3.0",
|
||||
"webpack-bundle-analyzer": "^5.1.1",
|
||||
"webpack-cli": "^5.1.0",
|
||||
"webpack-notifier": "^1.15.0"
|
||||
},
|
||||
@@ -46,6 +46,7 @@
|
||||
"@zxcvbn-ts/language-en": "^3.0.1",
|
||||
"@zxcvbn-ts/language-fr": "^3.0.1",
|
||||
"@zxcvbn-ts/language-ja": "^3.0.1",
|
||||
"attr-accept": "^2.2.5",
|
||||
"barcode-detector": "^3.0.5",
|
||||
"bootbox": "^6.0.0",
|
||||
"bootswatch": "^5.1.3",
|
||||
@@ -65,7 +66,7 @@
|
||||
"json-formatter-js": "^2.3.4",
|
||||
"jszip": "^3.2.0",
|
||||
"katex": "^0.16.0",
|
||||
"marked": "^16.1.1",
|
||||
"marked": "^17.0.1",
|
||||
"marked-gfm-heading-id": "^4.1.1",
|
||||
"marked-mangle": "^1.0.1",
|
||||
"pdfmake": "^0.2.2",
|
||||
@@ -73,5 +74,8 @@
|
||||
"tom-select": "^2.1.0",
|
||||
"ts-loader": "^9.2.6",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"jquery": "^3.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ parameters:
|
||||
- src
|
||||
# - tests
|
||||
|
||||
banned_code:
|
||||
non_ignorable: false # Allow to ignore some banned code
|
||||
|
||||
excludePaths:
|
||||
- src/DataTables/Adapter/*
|
||||
- src/Configuration/*
|
||||
@@ -61,3 +64,9 @@ parameters:
|
||||
|
||||
# Ignore error of unused WithPermPresetsTrait, as it is used in the migrations which are not analyzed by Phpstan
|
||||
- '#Trait App\\Migration\\WithPermPresetsTrait is used zero times and is not analysed#'
|
||||
-
|
||||
message: '#Should not use function "shell_exec"#'
|
||||
path: src/Services/System/UpdateExecutor.php
|
||||
|
||||
- message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#'
|
||||
path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
|
||||
|
||||
141
src/Command/MaintenanceModeCommand.php
Normal file
141
src/Command/MaintenanceModeCommand.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand('partdb:maintenance-mode', 'Enable/disable maintenance mode and set a message')]
|
||||
class MaintenanceModeCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UpdateExecutor $updateExecutor
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDefinition([
|
||||
new InputOption('enable', null, InputOption::VALUE_NONE, 'Enable maintenance mode'),
|
||||
new InputOption('disable', null, InputOption::VALUE_NONE, 'Disable maintenance mode'),
|
||||
new InputOption('status', null, InputOption::VALUE_NONE, 'Show current maintenance mode status'),
|
||||
new InputOption('message', null, InputOption::VALUE_REQUIRED, 'Optional maintenance message (explicit option)'),
|
||||
new InputArgument('message_arg', InputArgument::OPTIONAL, 'Optional maintenance message as a positional argument (preferred when writing message directly)')
|
||||
]);
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$enable = (bool)$input->getOption('enable');
|
||||
$disable = (bool)$input->getOption('disable');
|
||||
$status = (bool)$input->getOption('status');
|
||||
|
||||
// Accept message either via --message option or as positional argument
|
||||
$optionMessage = $input->getOption('message');
|
||||
$argumentMessage = $input->getArgument('message_arg');
|
||||
|
||||
// Prefer explicit --message option, otherwise use positional argument if provided
|
||||
$message = null;
|
||||
if (is_string($optionMessage) && $optionMessage !== '') {
|
||||
$message = $optionMessage;
|
||||
} elseif (is_string($argumentMessage) && $argumentMessage !== '') {
|
||||
$message = $argumentMessage;
|
||||
}
|
||||
|
||||
// If no action provided, show help
|
||||
if (!$enable && !$disable && !$status) {
|
||||
$io->text('Maintenance mode command. See usage below:');
|
||||
$this->printHelp($io);
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($enable && $disable) {
|
||||
$io->error('Conflicting options: specify either --enable or --disable, not both.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($status) {
|
||||
if ($this->updateExecutor->isMaintenanceMode()) {
|
||||
$info = $this->updateExecutor->getMaintenanceInfo();
|
||||
$reason = $info['reason'] ?? 'Unknown reason';
|
||||
$enabledAt = $info['enabled_at'] ?? 'Unknown time';
|
||||
|
||||
$io->success(sprintf('Maintenance mode is ENABLED (since %s).', $enabledAt));
|
||||
$io->text(sprintf('Reason: %s', $reason));
|
||||
} else {
|
||||
$io->success('Maintenance mode is DISABLED.');
|
||||
}
|
||||
|
||||
// If only status requested, exit
|
||||
if (!$enable && !$disable) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
if ($enable) {
|
||||
// Use provided message or fallback to a default English message
|
||||
$reason = is_string($message)
|
||||
? $message
|
||||
: 'The system is temporarily unavailable due to maintenance.';
|
||||
|
||||
$this->updateExecutor->enableMaintenanceMode($reason);
|
||||
|
||||
$io->success(sprintf('Maintenance mode enabled. Reason: %s', $reason));
|
||||
}
|
||||
|
||||
if ($disable) {
|
||||
$this->updateExecutor->disableMaintenanceMode();
|
||||
$io->success('Maintenance mode disabled.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
$io->error(sprintf('Unexpected error: %s', $e->getMessage()));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function printHelp(SymfonyStyle $io): void
|
||||
{
|
||||
$io->writeln('');
|
||||
$io->writeln('Usage:');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --enable [--message="Maintenance message"]');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --enable "Maintenance message"');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --disable');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --status');
|
||||
$io->writeln('');
|
||||
}
|
||||
|
||||
}
|
||||
253
src/Command/Migrations/DBPlatformConvertCommand.php
Normal file
253
src/Command/Migrations/DBPlatformConvertCommand.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Command\Migrations;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\ImportExportSystem\PartKeeprImporter\PKImportHelper;
|
||||
use Doctrine\Bundle\DoctrineBundle\ConnectionFactory;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||
use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager;
|
||||
use Doctrine\Migrations\Configuration\Migration\ExistingConfiguration;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Migrations\DependencyFactory;
|
||||
use Doctrine\ORM\Id\AssignedGenerator;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
#[AsCommand('partdb:migrations:convert-db-platform', 'Convert the database to a different platform')]
|
||||
class DBPlatformConvertCommand extends Command
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $targetEM,
|
||||
private readonly PKImportHelper $importHelper,
|
||||
private readonly DependencyFactory $dependencyFactory,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $kernelProjectDir,
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHelp('This command allows you to migrate the database from one database platform to another (e.g. from MySQL to PostgreSQL).')
|
||||
->addArgument('url', InputArgument::REQUIRED, 'The database connection URL of the source database to migrate from');
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$sourceEM = $this->getSourceEm($input->getArgument('url'));
|
||||
|
||||
//Check that both databases are not using the same driver
|
||||
if ($sourceEM->getConnection()->getDatabasePlatform()::class === $this->targetEM->getConnection()->getDatabasePlatform()::class) {
|
||||
$io->warning('Source and target database are using the same database platform / driver. This command is only intended to migrate between different database platforms (e.g. from MySQL to PostgreSQL).');
|
||||
if (!$io->confirm('Do you want to continue anyway?', false)) {
|
||||
$io->info('Aborting migration process.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->ensureVersionUpToDate($sourceEM);
|
||||
|
||||
$io->note('This command is still in development. If you encounter any problems, please report them to the issue tracker on GitHub.');
|
||||
$io->warning(sprintf('This command will delete all existing data in the target database "%s". Make sure that you have no important data in the database before you continue!',
|
||||
$this->targetEM->getConnection()->getDatabase() ?? 'unknown'
|
||||
));
|
||||
|
||||
//$users = $sourceEM->getRepository(User::class)->findAll();
|
||||
//dump($users);
|
||||
|
||||
$io->ask('Please type "DELETE ALL DATA" to continue.', '', function ($answer) {
|
||||
if (strtoupper($answer) !== 'DELETE ALL DATA') {
|
||||
throw new \RuntimeException('You did not type "DELETE ALL DATA"!');
|
||||
}
|
||||
return $answer;
|
||||
});
|
||||
|
||||
|
||||
// Example migration logic (to be replaced with actual migration code)
|
||||
$io->info('Starting database migration...');
|
||||
|
||||
//Disable all event listeners on target EM to avoid unwanted side effects
|
||||
$eventManager = $this->targetEM->getEventManager();
|
||||
foreach ($eventManager->getAllListeners() as $event => $listeners) {
|
||||
foreach ($listeners as $listener) {
|
||||
$eventManager->removeEventListener($event, $listener);
|
||||
}
|
||||
}
|
||||
|
||||
$io->info('Clear target database...');
|
||||
$this->importHelper->purgeDatabaseForImport($this->targetEM, ['internal', 'migration_versions']);
|
||||
|
||||
$metadata = $this->targetEM->getMetadataFactory()->getAllMetadata();
|
||||
|
||||
$io->info('Modifying entity metadata for migration...');
|
||||
//First we modify each entity metadata to have an persist cascade on all relations
|
||||
foreach ($metadata as $metadatum) {
|
||||
$entityClass = $metadatum->getName();
|
||||
$io->writeln('Modifying cascade and ID settings for entity: ' . $entityClass, OutputInterface::VERBOSITY_VERBOSE);
|
||||
|
||||
foreach ($metadatum->getAssociationNames() as $fieldName) {
|
||||
$mapping = $metadatum->getAssociationMapping($fieldName);
|
||||
$mapping->cascade = array_unique(array_merge($mapping->cascade, ['persist']));
|
||||
$mapping->fetch = ClassMetadata::FETCH_EAGER; //Avoid lazy loading issues during migration
|
||||
|
||||
$metadatum->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_NONE);
|
||||
$metadatum->setIdGenerator(new AssignedGenerator());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$io->progressStart(count($metadata));
|
||||
|
||||
//First we migrate users to avoid foreign key constraint issues
|
||||
$io->info('Migrating users first to avoid foreign key constraint issues...');
|
||||
$this->fixUsers($sourceEM);
|
||||
|
||||
//Afterward we migrate all entities
|
||||
foreach ($metadata as $metadatum) {
|
||||
//skip all superclasses
|
||||
if ($metadatum->isMappedSuperclass) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entityClass = $metadatum->getName();
|
||||
|
||||
$io->note('Migrating entity: ' . $entityClass);
|
||||
|
||||
$repo = $sourceEM->getRepository($entityClass);
|
||||
$items = $repo->findAll();
|
||||
foreach ($items as $index => $item) {
|
||||
$this->targetEM->persist($item);
|
||||
}
|
||||
$this->targetEM->flush();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
|
||||
|
||||
//Fix sequences / auto increment values on target database
|
||||
$io->info('Fixing sequences / auto increment values on target database...');
|
||||
$this->fixAutoIncrements($this->targetEM);
|
||||
|
||||
$io->success('Database migration completed successfully.');
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$io->info('Process took peak memory: ' . round(memory_get_peak_usage(true) / 1024 / 1024, 2) . ' MB');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a source EntityManager based on the given connection URL
|
||||
* @param string $url
|
||||
* @return EntityManagerInterface
|
||||
*/
|
||||
private function getSourceEm(string $url): EntityManagerInterface
|
||||
{
|
||||
//Replace any %kernel.project_dir% placeholders
|
||||
$url = str_replace('%kernel.project_dir%', $this->kernelProjectDir, $url);
|
||||
|
||||
$connectionFactory = new ConnectionFactory();
|
||||
$connection = $connectionFactory->createConnection(['url' => $url]);
|
||||
return new EntityManager($connection, $this->targetEM->getConfiguration());
|
||||
}
|
||||
|
||||
private function ensureVersionUpToDate(EntityManagerInterface $sourceEM): void
|
||||
{
|
||||
//Ensure that target database is up to date
|
||||
$migrationStatusCalculator = $this->dependencyFactory->getMigrationStatusCalculator();
|
||||
$newMigrations = $migrationStatusCalculator->getNewMigrations();
|
||||
if (count($newMigrations->getItems()) > 0) {
|
||||
throw new \RuntimeException("Target database is not up to date. Please run all migrations (with doctrine:migrations:migrate) before starting the migration process.");
|
||||
}
|
||||
|
||||
$sourceDependencyLoader = DependencyFactory::fromEntityManager(new ExistingConfiguration($this->dependencyFactory->getConfiguration()), new ExistingEntityManager($sourceEM));
|
||||
$sourceMigrationStatusCalculator = $sourceDependencyLoader->getMigrationStatusCalculator();
|
||||
$sourceNewMigrations = $sourceMigrationStatusCalculator->getNewMigrations();
|
||||
if (count($sourceNewMigrations->getItems()) > 0) {
|
||||
throw new \RuntimeException("Source database is not up to date. Please run all migrations (with doctrine:migrations:migrate) on the source database before starting the migration process.");
|
||||
}
|
||||
}
|
||||
|
||||
private function fixUsers(EntityManagerInterface $sourceEM): void
|
||||
{
|
||||
//To avoid a problem with (Column 'settings' cannot be null) in MySQL we need to migrate the user entities first
|
||||
//and fix the settings and backupCodes fields
|
||||
|
||||
$reflClass = new \ReflectionClass(User::class);
|
||||
foreach ($sourceEM->getRepository(User::class)->findAll() as $user) {
|
||||
foreach (['settings', 'backupCodes'] as $field) {
|
||||
$property = $reflClass->getProperty($field);
|
||||
if (!$property->isInitialized($user) || $property->getValue($user) === null) {
|
||||
$property->setValue($user, []);
|
||||
}
|
||||
}
|
||||
$this->targetEM->persist($user);
|
||||
}
|
||||
}
|
||||
|
||||
private function fixAutoIncrements(EntityManagerInterface $em): void
|
||||
{
|
||||
$connection = $em->getConnection();
|
||||
$platform = $connection->getDatabasePlatform();
|
||||
|
||||
if ($platform instanceof PostgreSQLPlatform) {
|
||||
$connection->executeStatement(
|
||||
//From: https://wiki.postgresql.org/wiki/Fixing_Sequences
|
||||
<<<SQL
|
||||
SELECT 'SELECT SETVAL(' ||
|
||||
quote_literal(quote_ident(PGT.schemaname) || '.' || quote_ident(S.relname)) ||
|
||||
', COALESCE(MAX(' ||quote_ident(C.attname)|| '), 1) ) FROM ' ||
|
||||
quote_ident(PGT.schemaname)|| '.'||quote_ident(T.relname)|| ';'
|
||||
FROM pg_class AS S,
|
||||
pg_depend AS D,
|
||||
pg_class AS T,
|
||||
pg_attribute AS C,
|
||||
pg_tables AS PGT
|
||||
WHERE S.relkind = 'S'
|
||||
AND S.oid = D.objid
|
||||
AND D.refobjid = T.oid
|
||||
AND D.refobjid = C.attrelid
|
||||
AND D.refobjsubid = C.attnum
|
||||
AND T.relname = PGT.tablename
|
||||
ORDER BY S.relname;
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
}
|
||||
445
src/Command/UpdateCommand.php
Normal file
445
src/Command/UpdateCommand.php
Normal file
@@ -0,0 +1,445 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'partdb:update', description: 'Check for and install Part-DB updates', aliases: ['app:update'])]
|
||||
class UpdateCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly UpdateChecker $updateChecker,
|
||||
private readonly UpdateExecutor $updateExecutor)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHelp(<<<'HELP'
|
||||
The <info>%command.name%</info> command checks for Part-DB updates and can install them.
|
||||
|
||||
<comment>Check for updates:</comment>
|
||||
<info>php %command.full_name% --check</info>
|
||||
|
||||
<comment>List available versions:</comment>
|
||||
<info>php %command.full_name% --list</info>
|
||||
|
||||
<comment>Update to the latest version:</comment>
|
||||
<info>php %command.full_name%</info>
|
||||
|
||||
<comment>Update to a specific version:</comment>
|
||||
<info>php %command.full_name% v2.6.0</info>
|
||||
|
||||
<comment>Update without creating a backup (faster but riskier):</comment>
|
||||
<info>php %command.full_name% --no-backup</info>
|
||||
|
||||
<comment>Non-interactive update for scripts:</comment>
|
||||
<info>php %command.full_name% --force</info>
|
||||
|
||||
<comment>View update logs:</comment>
|
||||
<info>php %command.full_name% --logs</info>
|
||||
HELP
|
||||
)
|
||||
->addArgument(
|
||||
'version',
|
||||
InputArgument::OPTIONAL,
|
||||
'Target version to update to (e.g., v2.6.0). If not specified, updates to the latest stable version.'
|
||||
)
|
||||
->addOption(
|
||||
'check',
|
||||
'c',
|
||||
InputOption::VALUE_NONE,
|
||||
'Only check for updates without installing'
|
||||
)
|
||||
->addOption(
|
||||
'list',
|
||||
'l',
|
||||
InputOption::VALUE_NONE,
|
||||
'List all available versions'
|
||||
)
|
||||
->addOption(
|
||||
'no-backup',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Skip creating a backup before updating (not recommended)'
|
||||
)
|
||||
->addOption(
|
||||
'force',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'Skip confirmation prompts'
|
||||
)
|
||||
->addOption(
|
||||
'include-prerelease',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Include pre-release versions'
|
||||
)
|
||||
->addOption(
|
||||
'logs',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Show recent update logs'
|
||||
)
|
||||
->addOption(
|
||||
'refresh',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Force refresh of cached version information'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// Handle --logs option
|
||||
if ($input->getOption('logs')) {
|
||||
return $this->showLogs($io);
|
||||
}
|
||||
|
||||
// Handle --refresh option
|
||||
if ($input->getOption('refresh')) {
|
||||
$io->text('Refreshing version information...');
|
||||
$this->updateChecker->refreshVersionInfo();
|
||||
$io->success('Version cache cleared.');
|
||||
}
|
||||
|
||||
// Handle --list option
|
||||
if ($input->getOption('list')) {
|
||||
return $this->listVersions($io, $input->getOption('include-prerelease'));
|
||||
}
|
||||
|
||||
// Get update status
|
||||
$status = $this->updateChecker->getUpdateStatus();
|
||||
|
||||
// Display current status
|
||||
$io->title('Part-DB Update Manager');
|
||||
|
||||
$this->displayStatus($io, $status);
|
||||
|
||||
// Handle --check option
|
||||
if ($input->getOption('check')) {
|
||||
return $this->checkOnly($io, $status);
|
||||
}
|
||||
|
||||
// Validate we can update
|
||||
$validationResult = $this->validateUpdate($io, $status);
|
||||
if ($validationResult !== null) {
|
||||
return $validationResult;
|
||||
}
|
||||
|
||||
// Determine target version
|
||||
$targetVersion = $input->getArgument('version');
|
||||
$includePrerelease = $input->getOption('include-prerelease');
|
||||
|
||||
if (!$targetVersion) {
|
||||
$latest = $this->updateChecker->getLatestVersion($includePrerelease);
|
||||
if (!$latest) {
|
||||
$io->error('Could not determine the latest version. Please specify a version manually.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$targetVersion = $latest['tag'];
|
||||
}
|
||||
|
||||
// Validate target version
|
||||
if (!$this->updateChecker->isNewerVersionThanCurrent($targetVersion)) {
|
||||
$io->warning(sprintf(
|
||||
'Version %s is not newer than the current version %s.',
|
||||
$targetVersion,
|
||||
$status['current_version']
|
||||
));
|
||||
|
||||
if (!$input->getOption('force')) {
|
||||
if (!$io->confirm('Do you want to proceed anyway?', false)) {
|
||||
$io->info('Update cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm update
|
||||
if (!$input->getOption('force')) {
|
||||
$io->section('Update Plan');
|
||||
|
||||
$io->listing([
|
||||
sprintf('Target version: <info>%s</info>', $targetVersion),
|
||||
$input->getOption('no-backup')
|
||||
? '<fg=yellow>Backup will be SKIPPED</>'
|
||||
: 'A full backup will be created before updating',
|
||||
'Maintenance mode will be enabled during update',
|
||||
'Database migrations will be run automatically',
|
||||
'Cache will be cleared and rebuilt',
|
||||
]);
|
||||
|
||||
$io->warning('The update process may take several minutes. Do not interrupt it.');
|
||||
|
||||
if (!$io->confirm('Do you want to proceed with the update?', false)) {
|
||||
$io->info('Update cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute update
|
||||
return $this->executeUpdate($io, $targetVersion, !$input->getOption('no-backup'));
|
||||
}
|
||||
|
||||
private function displayStatus(SymfonyStyle $io, array $status): void
|
||||
{
|
||||
$io->definitionList(
|
||||
['Current Version' => sprintf('<info>%s</info>', $status['current_version'])],
|
||||
['Latest Version' => $status['latest_version']
|
||||
? sprintf('<info>%s</info>', $status['latest_version'])
|
||||
: '<fg=yellow>Unknown</>'],
|
||||
['Installation Type' => $status['installation']['type_name']],
|
||||
['Git Branch' => $status['git']['branch'] ?? '<fg=gray>N/A</>'],
|
||||
['Git Commit' => $status['git']['commit'] ?? '<fg=gray>N/A</>'],
|
||||
['Local Changes' => $status['git']['has_local_changes']
|
||||
? '<fg=yellow>Yes (update blocked)</>'
|
||||
: '<fg=green>No</>'],
|
||||
['Commits Behind' => $status['git']['commits_behind'] > 0
|
||||
? sprintf('<fg=yellow>%d</>', $status['git']['commits_behind'])
|
||||
: '<fg=green>0</>'],
|
||||
['Update Available' => $status['update_available']
|
||||
? '<fg=green>Yes</>'
|
||||
: 'No'],
|
||||
['Can Auto-Update' => $status['can_auto_update']
|
||||
? '<fg=green>Yes</>'
|
||||
: '<fg=yellow>No</>'],
|
||||
);
|
||||
|
||||
if (!empty($status['update_blockers'])) {
|
||||
$io->warning('Update blockers: ' . implode(', ', $status['update_blockers']));
|
||||
}
|
||||
}
|
||||
|
||||
private function checkOnly(SymfonyStyle $io, array $status): int
|
||||
{
|
||||
if (!$status['check_enabled']) {
|
||||
$io->warning('Update checking is disabled in privacy settings.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($status['update_available']) {
|
||||
$io->success(sprintf(
|
||||
'A new version is available: %s (current: %s)',
|
||||
$status['latest_version'],
|
||||
$status['current_version']
|
||||
));
|
||||
|
||||
if ($status['release_url']) {
|
||||
$io->text(sprintf('Release notes: <href=%s>%s</>', $status['release_url'], $status['release_url']));
|
||||
}
|
||||
|
||||
if ($status['can_auto_update']) {
|
||||
$io->text('');
|
||||
$io->text('Run <info>php bin/console partdb:update</info> to update.');
|
||||
} else {
|
||||
$io->text('');
|
||||
$io->text($status['installation']['update_instructions']);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->success('You are running the latest version.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function validateUpdate(SymfonyStyle $io, array $status): ?int
|
||||
{
|
||||
// Check if update checking is enabled
|
||||
if (!$status['check_enabled']) {
|
||||
$io->error('Update checking is disabled in privacy settings. Enable it to use automatic updates.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Check installation type
|
||||
if (!$status['can_auto_update']) {
|
||||
$io->error('Automatic updates are not supported for this installation type.');
|
||||
$io->text($status['installation']['update_instructions']);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Validate preconditions
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$io->error('Cannot proceed with update:');
|
||||
$io->listing($validation['errors']);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executeUpdate(SymfonyStyle $io, string $targetVersion, bool $createBackup): int
|
||||
{
|
||||
$io->section('Executing Update');
|
||||
$io->text(sprintf('Updating to version: <info>%s</info>', $targetVersion));
|
||||
$io->text('');
|
||||
|
||||
$progressCallback = function (array $step) use ($io): void {
|
||||
$icon = $step['success'] ? '<fg=green>✓</>' : '<fg=red>✗</>';
|
||||
$duration = $step['duration'] ? sprintf(' <fg=gray>(%.1fs)</>', $step['duration']) : '';
|
||||
$io->text(sprintf(' %s <info>%s</info>: %s%s', $icon, $step['step'], $step['message'], $duration));
|
||||
};
|
||||
|
||||
// Use executeUpdateWithProgress to update the progress file for web UI
|
||||
$result = $this->updateExecutor->executeUpdateWithProgress($targetVersion, $createBackup, $progressCallback);
|
||||
|
||||
$io->text('');
|
||||
|
||||
if ($result['success']) {
|
||||
$io->success(sprintf(
|
||||
'Successfully updated to %s in %.1f seconds!',
|
||||
$targetVersion,
|
||||
$result['duration']
|
||||
));
|
||||
|
||||
$io->text([
|
||||
sprintf('Rollback tag: <info>%s</info>', $result['rollback_tag']),
|
||||
sprintf('Log file: <info>%s</info>', $result['log_file']),
|
||||
]);
|
||||
|
||||
$io->note('If you encounter any issues, you can rollback using: git checkout ' . $result['rollback_tag']);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->error('Update failed: ' . $result['error']);
|
||||
|
||||
if ($result['rollback_tag']) {
|
||||
$io->warning(sprintf('System was rolled back to: %s', $result['rollback_tag']));
|
||||
}
|
||||
|
||||
if ($result['log_file']) {
|
||||
$io->text(sprintf('See log file for details: %s', $result['log_file']));
|
||||
}
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
private function listVersions(SymfonyStyle $io, bool $includePrerelease): int
|
||||
{
|
||||
$releases = $this->updateChecker->getAvailableReleases(15);
|
||||
$currentVersion = $this->updateChecker->getCurrentVersionString();
|
||||
|
||||
if (empty($releases)) {
|
||||
$io->warning('Could not fetch available versions. Check your internet connection.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->title('Available Part-DB Versions');
|
||||
|
||||
$table = new Table($io);
|
||||
$table->setHeaders(['Tag', 'Version', 'Released', 'Status']);
|
||||
|
||||
foreach ($releases as $release) {
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $release['version'];
|
||||
$status = [];
|
||||
|
||||
if (version_compare($version, $currentVersion, '=')) {
|
||||
$status[] = '<fg=cyan>current</>';
|
||||
} elseif (version_compare($version, $currentVersion, '>')) {
|
||||
$status[] = '<fg=green>newer</>';
|
||||
}
|
||||
|
||||
if ($release['prerelease']) {
|
||||
$status[] = '<fg=yellow>pre-release</>';
|
||||
}
|
||||
|
||||
$table->addRow([
|
||||
$release['tag'],
|
||||
$version,
|
||||
(new \DateTime($release['published_at']))->format('Y-m-d'),
|
||||
implode(' ', $status) ?: '-',
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
|
||||
$io->text('');
|
||||
$io->text('Use <info>php bin/console partdb:update [tag]</info> to update to a specific version.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function showLogs(SymfonyStyle $io): int
|
||||
{
|
||||
$logs = $this->updateExecutor->getUpdateLogs();
|
||||
|
||||
if (empty($logs)) {
|
||||
$io->info('No update logs found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->title('Recent Update Logs');
|
||||
|
||||
$table = new Table($io);
|
||||
$table->setHeaders(['Date', 'File', 'Size']);
|
||||
|
||||
foreach (array_slice($logs, 0, 10) as $log) {
|
||||
$table->addRow([
|
||||
date('Y-m-d H:i:s', $log['date']),
|
||||
$log['file'],
|
||||
$this->formatBytes($log['size']),
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
|
||||
$io->text('');
|
||||
$io->text('Log files are stored in: <info>var/log/updates/</info>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$unitIndex = 0;
|
||||
|
||||
while ($bytes >= 1024 && $unitIndex < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
$unitIndex++;
|
||||
}
|
||||
|
||||
return sprintf('%.1f %s', $bytes, $units[$unitIndex]);
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,9 @@ declare(strict_types=1);
|
||||
*/
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -33,7 +33,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
#[AsCommand('partdb:version|app:version', 'Shows the currently installed version of Part-DB.')]
|
||||
class VersionCommand extends Command
|
||||
{
|
||||
public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfo $gitVersionInfo)
|
||||
public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfoProvider $gitVersionInfo)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -48,9 +48,9 @@ class VersionCommand extends Command
|
||||
|
||||
$message = 'Part-DB version: '. $this->versionManager->getVersion()->toString();
|
||||
|
||||
if ($this->gitVersionInfo->getGitBranchName() !== null) {
|
||||
$message .= ' Git branch: '. $this->gitVersionInfo->getGitBranchName();
|
||||
$message .= ', Git commit: '. $this->gitVersionInfo->getGitCommitHash();
|
||||
if ($this->gitVersionInfo->getBranchName() !== null) {
|
||||
$message .= ' Git branch: '. $this->gitVersionInfo->getBranchName();
|
||||
$message .= ', Git commit: '. $this->gitVersionInfo->getCommitHash();
|
||||
}
|
||||
|
||||
$io->success($message);
|
||||
|
||||
@@ -24,9 +24,9 @@ namespace App\Controller;
|
||||
|
||||
use App\DataTables\LogDataTable;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\BannerHelper;
|
||||
use App\Services\System\UpdateAvailableManager;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use App\Services\System\UpdateAvailableFacade;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -43,8 +43,8 @@ class HomepageController extends AbstractController
|
||||
|
||||
|
||||
#[Route(path: '/', name: 'homepage')]
|
||||
public function homepage(Request $request, GitVersionInfo $versionInfo, EntityManagerInterface $entityManager,
|
||||
UpdateAvailableManager $updateAvailableManager): Response
|
||||
public function homepage(Request $request, GitVersionInfoProvider $versionInfo, EntityManagerInterface $entityManager,
|
||||
UpdateAvailableFacade $updateAvailableManager): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
|
||||
|
||||
@@ -77,8 +77,8 @@ class HomepageController extends AbstractController
|
||||
|
||||
return $this->render('homepage.html.twig', [
|
||||
'banner' => $this->bannerHelper->getBanner(),
|
||||
'git_branch' => $versionInfo->getGitBranchName(),
|
||||
'git_commit' => $versionInfo->getGitCommitHash(),
|
||||
'git_branch' => $versionInfo->getBranchName(),
|
||||
'git_commit' => $versionInfo->getCommitHash(),
|
||||
'show_first_steps' => $show_first_steps,
|
||||
'datatable' => $table,
|
||||
'new_version_available' => $updateAvailableManager->isUpdateAvailable(),
|
||||
|
||||
@@ -30,6 +30,7 @@ use App\Form\InfoProviderSystem\PartSearchType;
|
||||
use App\Services\InfoProviderSystem\ExistingPartFinder;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Services\InfoProviderSystem\Providers\GenericWebProvider;
|
||||
use App\Settings\AppSettings;
|
||||
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -39,11 +40,15 @@ use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\HttpClient\Exception\ClientException;
|
||||
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
#[Route('/tools/info_providers')]
|
||||
@@ -178,6 +183,13 @@ class InfoProviderController extends AbstractController
|
||||
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
|
||||
} catch (OAuthReconnectRequiredException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.oauth_reconnect', ['%provider%' => $e->getProviderName()]));
|
||||
} catch (TransportException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.transport_exception'));
|
||||
$exceptionLogger->error('Transport error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.general_exception', ['%type%' => (new \ReflectionClass($e))->getShortName()]));
|
||||
//Log the exception
|
||||
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
|
||||
|
||||
@@ -198,4 +210,58 @@ class InfoProviderController extends AbstractController
|
||||
'update_target' => $update_target
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/from_url', name: 'info_providers_from_url')]
|
||||
public function fromURL(Request $request, GenericWebProvider $provider): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
if (!$provider->isActive()) {
|
||||
$this->addFlash('error', "Generic Web Provider is not active. Please enable it in the provider settings.");
|
||||
return $this->redirectToRoute('info_providers_list');
|
||||
}
|
||||
|
||||
$formBuilder = $this->createFormBuilder();
|
||||
$formBuilder->add('url', UrlType::class, [
|
||||
'label' => 'info_providers.from_url.url.label',
|
||||
'required' => true,
|
||||
]);
|
||||
$formBuilder->add('submit', SubmitType::class, [
|
||||
'label' => 'info_providers.search.submit',
|
||||
]);
|
||||
|
||||
$form = $formBuilder->getForm();
|
||||
$form->handleRequest($request);
|
||||
|
||||
$partDetail = null;
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
//Try to retrieve the part detail from the given URL
|
||||
$url = $form->get('url')->getData();
|
||||
try {
|
||||
$searchResult = $this->infoRetriever->searchByKeyword(
|
||||
keyword: $url,
|
||||
providers: [$provider]
|
||||
);
|
||||
|
||||
if (count($searchResult) === 0) {
|
||||
$this->addFlash('warning', t('info_providers.from_url.no_part_found'));
|
||||
} else {
|
||||
$searchResult = $searchResult[0];
|
||||
//Redirect to the part creation page with the found part detail
|
||||
return $this->redirectToRoute('info_providers_create_part', [
|
||||
'providerKey' => $searchResult->provider_key,
|
||||
'providerId' => $searchResult->provider_id,
|
||||
]);
|
||||
}
|
||||
} catch (ExceptionInterface $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.general_exception', ['%type%' => (new \ReflectionClass($e))->getShortName()]));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('info_providers/from_url/from_url.html.twig', [
|
||||
'form' => $form,
|
||||
'partDetail' => $partDetail,
|
||||
]);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +54,14 @@ use Exception;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
@@ -135,6 +137,7 @@ final class PartController extends AbstractController
|
||||
'description_params' => $this->partInfoSettings->extractParamsFromDescription ? $parameterExtractor->extractParameters($part->getDescription()) : [],
|
||||
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
|
||||
'withdraw_add_helper' => $withdrawAddHelper,
|
||||
'highlightLotId' => $request->query->getInt('highlightLot', 0),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -462,6 +465,54 @@ final class PartController extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/stocktake', name: 'part_stocktake', methods: ['POST'])]
|
||||
#[IsCsrfTokenValid(new Expression("'part_stocktake-' ~ args['part'].getid()"), '_token')]
|
||||
public function stocktakeHandler(Part $part, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper,
|
||||
Request $request,
|
||||
): Response
|
||||
{
|
||||
$partLot = $em->find(PartLot::class, $request->request->get('lot_id'));
|
||||
|
||||
//Check that the user is allowed to stocktake the partlot
|
||||
$this->denyAccessUnlessGranted('stocktake', $partLot);
|
||||
|
||||
if (!$partLot instanceof PartLot) {
|
||||
throw new \RuntimeException('Part lot not found!');
|
||||
}
|
||||
//Ensure that the partlot belongs to the part
|
||||
if ($partLot->getPart() !== $part) {
|
||||
throw new \RuntimeException("The origin partlot does not belong to the part!");
|
||||
}
|
||||
|
||||
$actualAmount = (float) $request->request->get('actual_amount');
|
||||
$comment = $request->request->get('comment');
|
||||
|
||||
$timestamp = null;
|
||||
$timestamp_str = $request->request->getString('timestamp', '');
|
||||
//Try to parse the timestamp
|
||||
if ($timestamp_str !== '') {
|
||||
$timestamp = new DateTime($timestamp_str);
|
||||
}
|
||||
|
||||
$withdrawAddHelper->stocktake($partLot, $actualAmount, $comment, $timestamp);
|
||||
|
||||
//Ensure that the timestamp is not in the future
|
||||
if ($timestamp !== null && $timestamp > new DateTime("+20min")) {
|
||||
throw new \LogicException("The timestamp must not be in the future!");
|
||||
}
|
||||
|
||||
//Save the changes to the DB
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'part.withdraw.success');
|
||||
|
||||
//If a redirect was passed, then redirect there
|
||||
if ($request->request->get('_redirect')) {
|
||||
return $this->redirect($request->request->get('_redirect'));
|
||||
}
|
||||
//Otherwise just redirect to the part page
|
||||
return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])]
|
||||
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
|
||||
{
|
||||
|
||||
@@ -319,6 +319,7 @@ class PartListsController extends AbstractController
|
||||
|
||||
//As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)!
|
||||
$filter->setName($request->query->getBoolean('name'));
|
||||
$filter->setDbId($request->query->getBoolean('dbid'));
|
||||
$filter->setCategory($request->query->getBoolean('category'));
|
||||
$filter->setDescription($request->query->getBoolean('description'));
|
||||
$filter->setMpn($request->query->getBoolean('mpn'));
|
||||
|
||||
@@ -27,8 +27,8 @@ use App\Services\Attachments\AttachmentURLGenerator;
|
||||
use App\Services\Attachments\BuiltinAttachmentsFinder;
|
||||
use App\Services\Doctrine\DBInfoHelper;
|
||||
use App\Services\Doctrine\NatsortDebugHelper;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\UpdateAvailableManager;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use App\Services\System\UpdateAvailableFacade;
|
||||
use App\Settings\AppSettings;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -47,16 +47,16 @@ class ToolsController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/server_infos', name: 'tools_server_infos')]
|
||||
public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager,
|
||||
public function systemInfos(GitVersionInfoProvider $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableFacade $updateAvailableManager,
|
||||
AppSettings $settings): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.server_infos');
|
||||
|
||||
return $this->render('tools/server_infos/server_infos.html.twig', [
|
||||
//Part-DB section
|
||||
'git_branch' => $versionInfo->getGitBranchName(),
|
||||
'git_commit' => $versionInfo->getGitCommitHash(),
|
||||
'git_branch' => $versionInfo->getBranchName(),
|
||||
'git_commit' => $versionInfo->getCommitHash(),
|
||||
'default_locale' => $settings->system->localization->locale,
|
||||
'default_timezone' => $settings->system->localization->timezone,
|
||||
'default_currency' => $settings->system->localization->baseCurrency,
|
||||
|
||||
371
src/Controller/UpdateManagerController.php
Normal file
371
src/Controller/UpdateManagerController.php
Normal file
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Services\System\BackupManager;
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Controller for the Update Manager web interface.
|
||||
*
|
||||
* This provides a read-only view of update status and instructions.
|
||||
* Actual updates should be performed via the CLI command for safety.
|
||||
*/
|
||||
#[Route('/system/update-manager')]
|
||||
class UpdateManagerController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UpdateChecker $updateChecker,
|
||||
private readonly UpdateExecutor $updateExecutor,
|
||||
private readonly VersionManagerInterface $versionManager,
|
||||
private readonly BackupManager $backupManager,
|
||||
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
|
||||
private readonly bool $webUpdatesDisabled = false,
|
||||
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
|
||||
private readonly bool $backupRestoreDisabled = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if web updates are disabled and throw exception if so.
|
||||
*/
|
||||
private function denyIfWebUpdatesDisabled(): void
|
||||
{
|
||||
if ($this->webUpdatesDisabled) {
|
||||
throw new AccessDeniedHttpException('Web-based updates are disabled by server configuration. Please use the CLI command instead.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if backup restore is disabled and throw exception if so.
|
||||
*/
|
||||
private function denyIfBackupRestoreDisabled(): void
|
||||
{
|
||||
if ($this->backupRestoreDisabled) {
|
||||
throw new AccessDeniedHttpException('Backup restore is disabled by server configuration.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main update manager page.
|
||||
*/
|
||||
#[Route('', name: 'admin_update_manager', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$status = $this->updateChecker->getUpdateStatus();
|
||||
$availableUpdates = $this->updateChecker->getAvailableUpdates();
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
|
||||
return $this->render('admin/update_manager/index.html.twig', [
|
||||
'status' => $status,
|
||||
'available_updates' => $availableUpdates,
|
||||
'all_releases' => $this->updateChecker->getAvailableReleases(10),
|
||||
'validation' => $validation,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'lock_info' => $this->updateExecutor->getLockInfo(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(),
|
||||
'update_logs' => $this->updateExecutor->getUpdateLogs(),
|
||||
'backups' => $this->backupManager->getBackups(),
|
||||
'web_updates_disabled' => $this->webUpdatesDisabled,
|
||||
'backup_restore_disabled' => $this->backupRestoreDisabled,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to check update status.
|
||||
*/
|
||||
#[Route('/status', name: 'admin_update_manager_status', methods: ['GET'])]
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
return $this->json([
|
||||
'status' => $this->updateChecker->getUpdateStatus(),
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'lock_info' => $this->updateExecutor->getLockInfo(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to refresh version information.
|
||||
*/
|
||||
#[Route('/refresh', name: 'admin_update_manager_refresh', methods: ['POST'])]
|
||||
public function refresh(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_refresh', $request->request->get('_token'))) {
|
||||
return $this->json(['error' => 'Invalid CSRF token'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$this->updateChecker->refreshVersionInfo();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'status' => $this->updateChecker->getUpdateStatus(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View release notes for a specific version.
|
||||
*/
|
||||
#[Route('/release/{tag}', name: 'admin_update_manager_release', methods: ['GET'])]
|
||||
public function releaseNotes(string $tag): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$releases = $this->updateChecker->getAvailableReleases(20);
|
||||
$release = null;
|
||||
|
||||
foreach ($releases as $r) {
|
||||
if ($r['tag'] === $tag) {
|
||||
$release = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$release) {
|
||||
throw $this->createNotFoundException('Release not found');
|
||||
}
|
||||
|
||||
return $this->render('admin/update_manager/release_notes.html.twig', [
|
||||
'release' => $release,
|
||||
'current_version' => $this->updateChecker->getCurrentVersionString(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View an update log file.
|
||||
*/
|
||||
#[Route('/log/{filename}', name: 'admin_update_manager_log', methods: ['GET'])]
|
||||
public function viewLog(string $filename): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
// Security: Only allow viewing files from the update logs directory
|
||||
$logs = $this->updateExecutor->getUpdateLogs();
|
||||
$logPath = null;
|
||||
|
||||
foreach ($logs as $log) {
|
||||
if ($log['file'] === $filename) {
|
||||
$logPath = $log['path'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$logPath || !file_exists($logPath)) {
|
||||
throw $this->createNotFoundException('Log file not found');
|
||||
}
|
||||
|
||||
$content = file_get_contents($logPath);
|
||||
|
||||
return $this->render('admin/update_manager/log_viewer.html.twig', [
|
||||
'filename' => $filename,
|
||||
'content' => $content,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an update process.
|
||||
*/
|
||||
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
|
||||
public function startUpdate(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfWebUpdatesDisabled();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_start', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Check if update is already running
|
||||
if ($this->updateExecutor->isLocked() || $this->updateExecutor->isUpdateRunning()) {
|
||||
$this->addFlash('error', 'An update is already in progress.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$targetVersion = $request->request->get('version');
|
||||
$createBackup = $request->request->getBoolean('backup', true);
|
||||
|
||||
if (!$targetVersion) {
|
||||
// Get latest version if not specified
|
||||
$latest = $this->updateChecker->getLatestVersion();
|
||||
if (!$latest) {
|
||||
$this->addFlash('error', 'Could not determine target version.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
$targetVersion = $latest['tag'];
|
||||
}
|
||||
|
||||
// Validate preconditions
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$this->addFlash('error', implode(' ', $validation['errors']));
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Start the background update
|
||||
$pid = $this->updateExecutor->startBackgroundUpdate($targetVersion, $createBackup);
|
||||
|
||||
if (!$pid) {
|
||||
$this->addFlash('error', 'Failed to start update process.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Redirect to progress page
|
||||
return $this->redirectToRoute('admin_update_manager_progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress page.
|
||||
*/
|
||||
#[Route('/progress', name: 'admin_update_manager_progress', methods: ['GET'])]
|
||||
public function progress(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
$progress = $this->updateExecutor->getProgress();
|
||||
$currentVersion = $this->versionManager->getVersion()->toString();
|
||||
|
||||
// Determine if this is a downgrade
|
||||
$isDowngrade = false;
|
||||
if ($progress && isset($progress['target_version'])) {
|
||||
$targetVersion = ltrim($progress['target_version'], 'v');
|
||||
$isDowngrade = version_compare($targetVersion, $currentVersion, '<');
|
||||
}
|
||||
|
||||
return $this->render('admin/update_manager/progress.html.twig', [
|
||||
'progress' => $progress,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'is_downgrade' => $isDowngrade,
|
||||
'current_version' => $currentVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to get update progress.
|
||||
*/
|
||||
#[Route('/progress/status', name: 'admin_update_manager_progress_status', methods: ['GET'])]
|
||||
public function progressStatus(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$progress = $this->updateExecutor->getProgress();
|
||||
|
||||
return $this->json([
|
||||
'progress' => $progress,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup details for restore confirmation.
|
||||
*/
|
||||
#[Route('/backup/{filename}', name: 'admin_update_manager_backup_details', methods: ['GET'])]
|
||||
public function backupDetails(string $filename): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
$details = $this->backupManager->getBackupDetails($filename);
|
||||
|
||||
if (!$details) {
|
||||
return $this->json(['error' => 'Backup not found'], 404);
|
||||
}
|
||||
|
||||
return $this->json($details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a backup.
|
||||
*/
|
||||
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
|
||||
public function restore(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfBackupRestoreDisabled();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_restore', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Check if already locked
|
||||
if ($this->updateExecutor->isLocked()) {
|
||||
$this->addFlash('error', 'An update or restore is already in progress.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$filename = $request->request->get('filename');
|
||||
$restoreDatabase = $request->request->getBoolean('restore_database', true);
|
||||
$restoreConfig = $request->request->getBoolean('restore_config', false);
|
||||
$restoreAttachments = $request->request->getBoolean('restore_attachments', false);
|
||||
|
||||
if (!$filename) {
|
||||
$this->addFlash('error', 'No backup file specified.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Verify the backup exists
|
||||
$backupDetails = $this->backupManager->getBackupDetails($filename);
|
||||
if (!$backupDetails) {
|
||||
$this->addFlash('error', 'Backup file not found.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Execute restore (this is a synchronous operation for now - could be made async later)
|
||||
$result = $this->updateExecutor->restoreBackup(
|
||||
$filename,
|
||||
$restoreDatabase,
|
||||
$restoreConfig,
|
||||
$restoreAttachments
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->addFlash('success', 'Backup restored successfully.');
|
||||
} else {
|
||||
$this->addFlash('error', 'Restore failed: ' . ($result['error'] ?? 'Unknown error'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ class PartFilter implements FilterInterface
|
||||
public readonly BooleanConstraint $favorite;
|
||||
public readonly BooleanConstraint $needsReview;
|
||||
public readonly NumberConstraint $mass;
|
||||
public readonly TextConstraint $gtin;
|
||||
public readonly DateTimeConstraint $lastModified;
|
||||
public readonly DateTimeConstraint $addedDate;
|
||||
public readonly EntityConstraint $category;
|
||||
@@ -132,6 +133,7 @@ class PartFilter implements FilterInterface
|
||||
$this->measurementUnit = new EntityConstraint($nodesListBuilder, MeasurementUnit::class, 'part.partUnit');
|
||||
$this->partCustomState = new EntityConstraint($nodesListBuilder, PartCustomState::class, 'part.partCustomState');
|
||||
$this->mass = new NumberConstraint('part.mass');
|
||||
$this->gtin = new TextConstraint('part.gtin');
|
||||
$this->dbId = new IntConstraint('part.id');
|
||||
$this->ipn = new TextConstraint('part.ipn');
|
||||
$this->addedDate = new DateTimeConstraint('part.addedDate');
|
||||
|
||||
@@ -23,6 +23,7 @@ declare(strict_types=1);
|
||||
namespace App\DataTables\Filters;
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
|
||||
class PartSearchFilter implements FilterInterface
|
||||
{
|
||||
@@ -33,6 +34,9 @@ class PartSearchFilter implements FilterInterface
|
||||
/** @var bool Use name field for searching */
|
||||
protected bool $name = true;
|
||||
|
||||
/** @var bool Use id field for searching */
|
||||
protected bool $dbId = false;
|
||||
|
||||
/** @var bool Use category name for searching */
|
||||
protected bool $category = true;
|
||||
|
||||
@@ -120,33 +124,51 @@ class PartSearchFilter implements FilterInterface
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
$fields_to_search = $this->getFieldsToSearch();
|
||||
$is_numeric = preg_match('/^\d+$/', $this->keyword) === 1;
|
||||
|
||||
// Add exact ID match only when the keyword is numeric
|
||||
$search_dbId = $is_numeric && (bool)$this->dbId;
|
||||
|
||||
//If we have nothing to search for, do nothing
|
||||
if ($fields_to_search === [] || $this->keyword === '') {
|
||||
if (($fields_to_search === [] && !$search_dbId) || $this->keyword === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
//Convert the fields to search to a list of expressions
|
||||
$expressions = array_map(function (string $field): string {
|
||||
$expressions = [];
|
||||
|
||||
if($fields_to_search !== []) {
|
||||
//Convert the fields to search to a list of expressions
|
||||
$expressions = array_map(function (string $field): string {
|
||||
if ($this->regex) {
|
||||
return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
|
||||
}
|
||||
|
||||
return sprintf("ILIKE(%s, :search_query) = TRUE", $field);
|
||||
}, $fields_to_search);
|
||||
|
||||
//For regex, we pass the query as is, for like we add % to the start and end as wildcards
|
||||
if ($this->regex) {
|
||||
return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
|
||||
$queryBuilder->setParameter('search_query', $this->keyword);
|
||||
} else {
|
||||
//Escape % and _ characters in the keyword
|
||||
$this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword);
|
||||
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
|
||||
}
|
||||
}
|
||||
|
||||
return sprintf("ILIKE(%s, :search_query) = TRUE", $field);
|
||||
}, $fields_to_search);
|
||||
//Use equal expression to just search for exact numeric matches
|
||||
if ($search_dbId) {
|
||||
$expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact');
|
||||
$queryBuilder->setParameter('id_exact', (int) $this->keyword,
|
||||
\Doctrine\DBAL\ParameterType::INTEGER);
|
||||
}
|
||||
|
||||
//Add Or concatenation of the expressions to our query
|
||||
$queryBuilder->andWhere(
|
||||
$queryBuilder->expr()->orX(...$expressions)
|
||||
);
|
||||
|
||||
//For regex, we pass the query as is, for like we add % to the start and end as wildcards
|
||||
if ($this->regex) {
|
||||
$queryBuilder->setParameter('search_query', $this->keyword);
|
||||
} else {
|
||||
//Escape % and _ characters in the keyword
|
||||
$this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword);
|
||||
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
|
||||
//Guard condition
|
||||
if (!empty($expressions)) {
|
||||
//Add Or concatenation of the expressions to our query
|
||||
$queryBuilder->andWhere(
|
||||
$queryBuilder->expr()->orX(...$expressions)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +205,17 @@ class PartSearchFilter implements FilterInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDbId(): bool
|
||||
{
|
||||
return $this->dbId;
|
||||
}
|
||||
|
||||
public function setDbId(bool $dbId): PartSearchFilter
|
||||
{
|
||||
$this->dbId = $dbId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isCategory(): bool
|
||||
{
|
||||
return $this->category;
|
||||
|
||||
@@ -208,6 +208,7 @@ class LogDataTable implements DataTableTypeInterface
|
||||
|
||||
$dataTable->add('extra', LogEntryExtraColumn::class, [
|
||||
'label' => 'log.extra',
|
||||
'orderable' => false, //Sorting the JSON column makes no sense: MySQL/Sqlite does it via the string representation, PostgreSQL errors out
|
||||
]);
|
||||
|
||||
$dataTable->add('timeTravel', IconLinkColumn::class, [
|
||||
|
||||
@@ -218,6 +218,10 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
'label' => $this->translator->trans('part.table.mass'),
|
||||
'unit' => 'g'
|
||||
])
|
||||
->add('gtin', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.gtin'),
|
||||
'orderField' => 'NATSORT(part.gtin)'
|
||||
])
|
||||
->add('tags', TagsColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.tags'),
|
||||
])
|
||||
|
||||
@@ -29,6 +29,7 @@ use App\DataTables\Helpers\PartDataTableHelper;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Formatters\AmountFormatter;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
@@ -41,7 +42,8 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||
{
|
||||
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
|
||||
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper,
|
||||
protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -79,7 +81,14 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||
return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()));
|
||||
},
|
||||
])
|
||||
|
||||
->add('partId', TextColumn::class, [
|
||||
'label' => $this->translator->trans('project.bom.part_id'),
|
||||
'visible' => true,
|
||||
'orderField' => 'part.id',
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
return $context->getPart() instanceof Part ? (string) $context->getPart()->getId() : '';
|
||||
},
|
||||
])
|
||||
->add('name', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.name'),
|
||||
'orderField' => 'NATSORT(part.name)',
|
||||
|
||||
@@ -104,7 +104,7 @@ final class FieldHelper
|
||||
{
|
||||
$db_platform = $qb->getEntityManager()->getConnection()->getDatabasePlatform();
|
||||
|
||||
$key = 'field2_' . md5($field_expr);
|
||||
$key = 'field2_' . hash('xxh3', $field_expr);
|
||||
|
||||
//If we are on MySQL, we can just use the FIELD function
|
||||
if ($db_platform instanceof AbstractMySQLPlatform) {
|
||||
@@ -121,4 +121,4 @@ final class FieldHelper
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ use function in_array;
|
||||
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
|
||||
abstract class Attachment extends AbstractNamedDBElement
|
||||
{
|
||||
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
|
||||
final public const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
|
||||
'AttachmentType' => AttachmentTypeAttachment::class,
|
||||
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
|
||||
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
|
||||
@@ -136,7 +136,7 @@ abstract class Attachment extends AbstractNamedDBElement
|
||||
* @var string The class of the element that can be passed to this attachment. Must be overridden in subclasses.
|
||||
* @phpstan-var class-string<T>
|
||||
*/
|
||||
protected const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
|
||||
public const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
|
||||
|
||||
/**
|
||||
* @var AttachmentUpload|null The options used for uploading a file to this attachment or modify it.
|
||||
|
||||
@@ -134,6 +134,17 @@ class AttachmentType extends AbstractStructuralDBElement
|
||||
#[ORM\OneToMany(mappedBy: 'attachment_type', targetEntity: Attachment::class)]
|
||||
protected Collection $attachments_with_type;
|
||||
|
||||
/**
|
||||
* @var string[]|null A list of allowed targets where this attachment type can be assigned to, as a list of portable names
|
||||
*/
|
||||
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
|
||||
protected ?array $allowed_targets = null;
|
||||
|
||||
/**
|
||||
* @var class-string<Attachment>[]|null
|
||||
*/
|
||||
protected ?array $allowed_targets_parsed_cache = null;
|
||||
|
||||
#[Groups(['attachment_type:read'])]
|
||||
protected ?\DateTimeImmutable $addedDate = null;
|
||||
#[Groups(['attachment_type:read'])]
|
||||
@@ -184,4 +195,81 @@ class AttachmentType extends AbstractStructuralDBElement
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of allowed targets as class names (e.g. PartAttachment::class), where this attachment type can be assigned to. If null, there are no restrictions.
|
||||
* @return class-string<Attachment>[]|null
|
||||
*/
|
||||
public function getAllowedTargets(): ?array
|
||||
{
|
||||
//Use cached value if available
|
||||
if ($this->allowed_targets_parsed_cache !== null) {
|
||||
return $this->allowed_targets_parsed_cache;
|
||||
}
|
||||
|
||||
if (empty($this->allowed_targets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tmp = [];
|
||||
foreach ($this->allowed_targets as $target) {
|
||||
if (isset(Attachment::ORM_DISCRIMINATOR_MAP[$target])) {
|
||||
$tmp[] = Attachment::ORM_DISCRIMINATOR_MAP[$target];
|
||||
}
|
||||
//Otherwise ignore the entry, as it is invalid
|
||||
}
|
||||
|
||||
//Cache the parsed value
|
||||
$this->allowed_targets_parsed_cache = $tmp;
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the allowed targets for this attachment type. Allowed targets are specified as a list of class names (e.g. PartAttachment::class). If null is passed, there are no restrictions.
|
||||
* @param class-string<Attachment>[]|null $allowed_targets
|
||||
* @return $this
|
||||
*/
|
||||
public function setAllowedTargets(?array $allowed_targets): self
|
||||
{
|
||||
if ($allowed_targets === null) {
|
||||
$this->allowed_targets = null;
|
||||
} else {
|
||||
$tmp = [];
|
||||
foreach ($allowed_targets as $target) {
|
||||
$discriminator = array_search($target, Attachment::ORM_DISCRIMINATOR_MAP, true);
|
||||
if ($discriminator !== false) {
|
||||
$tmp[] = $discriminator;
|
||||
} else {
|
||||
throw new \InvalidArgumentException("Invalid allowed target: $target. Allowed targets must be a class name of an Attachment subclass.");
|
||||
}
|
||||
}
|
||||
$this->allowed_targets = $tmp;
|
||||
}
|
||||
|
||||
//Reset the cache
|
||||
$this->allowed_targets_parsed_cache = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this attachment type is allowed for the given attachment target.
|
||||
* @param Attachment|string $attachment
|
||||
* @return bool
|
||||
*/
|
||||
public function isAllowedForTarget(Attachment|string $attachment): bool
|
||||
{
|
||||
//If no restrictions are set, allow all targets
|
||||
if ($this->getAllowedTargets() === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//Iterate over all allowed targets and check if the attachment is an instance of any of them
|
||||
foreach ($this->getAllowedTargets() as $allowed_target) {
|
||||
if (is_a($attachment, $allowed_target, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class EDACategoryInfo
|
||||
/** @var bool|null If this is set to true, then this part will be excluded in the simulation */
|
||||
#[Column(type: Types::BOOLEAN, nullable: true)]
|
||||
#[Groups(['full', 'category:read', 'category:write', 'import'])]
|
||||
private ?bool $exclude_from_sim = true;
|
||||
private ?bool $exclude_from_sim = null;
|
||||
|
||||
/** @var string|null The KiCAD schematic symbol, which should be used (the path to the library) */
|
||||
#[Column(type: Types::STRING, nullable: true)]
|
||||
|
||||
@@ -28,6 +28,8 @@ enum PartStockChangeType: string
|
||||
case WITHDRAW = "withdraw";
|
||||
case MOVE = "move";
|
||||
|
||||
case STOCKTAKE = "stock_take";
|
||||
|
||||
/**
|
||||
* Converts the type to a short representation usable in the extra field of the log entry.
|
||||
* @return string
|
||||
@@ -38,6 +40,7 @@ enum PartStockChangeType: string
|
||||
self::ADD => 'a',
|
||||
self::WITHDRAW => 'w',
|
||||
self::MOVE => 'm',
|
||||
self::STOCKTAKE => 's',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +55,7 @@ enum PartStockChangeType: string
|
||||
'a' => self::ADD,
|
||||
'w' => self::WITHDRAW,
|
||||
'm' => self::MOVE,
|
||||
's' => self::STOCKTAKE,
|
||||
default => throw new \InvalidArgumentException("Invalid short type: $value"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,6 +122,11 @@ class PartStockChangedLogEntry extends AbstractLogEntry
|
||||
return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target, action_timestamp: $action_timestamp);
|
||||
}
|
||||
|
||||
public static function stocktake(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self
|
||||
{
|
||||
return new self(PartStockChangeType::STOCKTAKE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the instock change type of this entry
|
||||
* @return PartStockChangeType
|
||||
|
||||
@@ -80,6 +80,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')]
|
||||
#[ORM\Index(columns: ['name'], name: 'parts_idx_name')]
|
||||
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
|
||||
#[ORM\Index(columns: ['gtin'], name: 'parts_idx_gtin')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(normalizationContext: [
|
||||
|
||||
@@ -171,6 +171,14 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
|
||||
#[Length(max: 255)]
|
||||
protected ?string $user_barcode = null;
|
||||
|
||||
/**
|
||||
* @var \DateTimeImmutable|null The date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet.
|
||||
*/
|
||||
#[Groups(['extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
|
||||
#[ORM\Column( type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
#[Year2038BugWorkaround]
|
||||
protected ?\DateTimeImmutable $last_stocktake_at = null;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id) {
|
||||
@@ -391,6 +399,26 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the date when the last stocktake was performed for this part lot. Returns null, if no stocktake was performed yet.
|
||||
* @return \DateTimeImmutable|null
|
||||
*/
|
||||
public function getLastStocktakeAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->last_stocktake_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet.
|
||||
* @param \DateTimeImmutable|null $last_stocktake_at
|
||||
* @return $this
|
||||
*/
|
||||
public function setLastStocktakeAt(?\DateTimeImmutable $last_stocktake_at): self
|
||||
{
|
||||
$this->last_stocktake_at = $last_stocktake_at;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[Assert\Callback]
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace App\Entity\Parts\PartTraits;
|
||||
|
||||
use App\Entity\Parts\InfoProviderReference;
|
||||
use App\Entity\Parts\PartCustomState;
|
||||
use App\Validator\Constraints\ValidGTIN;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -84,6 +85,14 @@ trait AdvancedPropertyTrait
|
||||
#[ORM\JoinColumn(name: 'id_part_custom_state')]
|
||||
protected ?PartCustomState $partCustomState = null;
|
||||
|
||||
/**
|
||||
* @var string|null The GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code
|
||||
*/
|
||||
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
|
||||
#[ORM\Column(type: Types::STRING, nullable: true)]
|
||||
#[ValidGTIN]
|
||||
protected ?string $gtin = null;
|
||||
|
||||
/**
|
||||
* Checks if this part is marked, for that it needs further review.
|
||||
*/
|
||||
@@ -211,4 +220,26 @@ trait AdvancedPropertyTrait
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code.
|
||||
* Returns null if no GTIN is set.
|
||||
*/
|
||||
public function getGtin(): ?string
|
||||
{
|
||||
return $this->gtin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code.
|
||||
*
|
||||
* @param string|null $gtin The new GTIN of the part
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setGtin(?string $gtin): self
|
||||
{
|
||||
$this->gtin = $gtin;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Serializer\Annotation\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
|
||||
@@ -147,6 +148,13 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
||||
#[ORM\JoinColumn(name: 'id_supplier')]
|
||||
protected ?Supplier $supplier = null;
|
||||
|
||||
/**
|
||||
* @var bool|null Whether the prices includes VAT or not. Null means, that it is not specified, if the prices includes VAT or not.
|
||||
*/
|
||||
#[ORM\Column(type: Types::BOOLEAN, nullable: true)]
|
||||
#[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
|
||||
protected ?bool $prices_includes_vat = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pricedetails = new ArrayCollection();
|
||||
@@ -388,6 +396,28 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the prices of this orderdetail include VAT. Null means, that it is not specified, if the prices includes
|
||||
* VAT or not.
|
||||
* @return bool|null
|
||||
*/
|
||||
public function getPricesIncludesVAT(): ?bool
|
||||
{
|
||||
return $this->prices_includes_vat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the prices of this orderdetail include VAT.
|
||||
* @param bool|null $includesVat
|
||||
* @return $this
|
||||
*/
|
||||
public function setPricesIncludesVAT(?bool $includesVat): self
|
||||
{
|
||||
$this->prices_includes_vat = $includesVat;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->getSupplierPartNr();
|
||||
|
||||
@@ -121,6 +121,8 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
|
||||
#[Groups(['pricedetail:read:standalone', 'pricedetail:write'])]
|
||||
protected ?Orderdetail $orderdetail = null;
|
||||
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->price = BigDecimal::zero()->toScale(self::PRICE_PRECISION);
|
||||
@@ -264,6 +266,15 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
|
||||
return $this->currency?->getIsoCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not.
|
||||
* @return bool|null
|
||||
*/
|
||||
public function getIncludesVat(): ?bool
|
||||
{
|
||||
return $this->orderdetail?->getPricesIncludesVAT();
|
||||
}
|
||||
|
||||
/********************************************************************************
|
||||
*
|
||||
* Setters
|
||||
|
||||
@@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable
|
||||
/**
|
||||
* The current schema version of the permission data
|
||||
*/
|
||||
public const CURRENT_SCHEMA_VERSION = 3;
|
||||
public const CURRENT_SCHEMA_VERSION = 4;
|
||||
|
||||
/**
|
||||
* Creates a new Permission Data Instance using the given data.
|
||||
|
||||
230
src/EventSubscriber/MaintenanceModeSubscriber.php
Normal file
230
src/EventSubscriber/MaintenanceModeSubscriber.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Blocks all web requests when maintenance mode is enabled during updates.
|
||||
*/
|
||||
readonly class MaintenanceModeSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private UpdateExecutor $updateExecutor)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
// High priority to run before other listeners
|
||||
KernelEvents::REQUEST => ['onKernelRequest', 512], //High priority to run before other listeners
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
// Only handle main requests
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not in maintenance mode
|
||||
if (!$this->updateExecutor->isMaintenanceMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Allow to view the progress page
|
||||
if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow CLI requests
|
||||
if (PHP_SAPI === 'cli') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get maintenance info
|
||||
$maintenanceInfo = $this->updateExecutor->getMaintenanceInfo();
|
||||
|
||||
// Calculate how long the update has been running
|
||||
$duration = null;
|
||||
if ($maintenanceInfo && isset($maintenanceInfo['enabled_at'])) {
|
||||
try {
|
||||
$startedAt = new \DateTime($maintenanceInfo['enabled_at']);
|
||||
$now = new \DateTime();
|
||||
$duration = $now->getTimestamp() - $startedAt->getTimestamp();
|
||||
} catch (\Exception) {
|
||||
// Ignore date parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
$content = $this->getSimpleMaintenanceHtml($maintenanceInfo, $duration);
|
||||
|
||||
$response = new Response($content, Response::HTTP_SERVICE_UNAVAILABLE);
|
||||
$response->headers->set('Retry-After', '30');
|
||||
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
|
||||
$event->setResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple maintenance page HTML without Twig.
|
||||
*/
|
||||
private function getSimpleMaintenanceHtml(?array $maintenanceInfo, ?int $duration): string
|
||||
{
|
||||
$reason = htmlspecialchars($maintenanceInfo['reason'] ?? 'Update in progress');
|
||||
$durationText = $duration !== null ? sprintf('%d seconds', $duration) : 'a moment';
|
||||
|
||||
$startDateStr = $maintenanceInfo['enabled_at'] ?? 'unknown time';
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="15">
|
||||
<title>Part-DB - Maintenance</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
max-width: 600px;
|
||||
}
|
||||
.icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 30px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #00d4ff;
|
||||
}
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
color: #b8c5d6;
|
||||
}
|
||||
.reason {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 15px 25px;
|
||||
border-radius: 10px;
|
||||
margin: 20px 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.progress-bar-inner {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00d4ff, #00ff88);
|
||||
border-radius: 3px;
|
||||
animation: progress 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes progress {
|
||||
0% { width: 0%; margin-left: 0%; }
|
||||
50% { width: 50%; margin-left: 25%; }
|
||||
100% { width: 0%; margin-left: 100%; }
|
||||
}
|
||||
.info {
|
||||
font-size: 0.9rem;
|
||||
color: #8899aa;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.duration {
|
||||
font-family: monospace;
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">
|
||||
<span class="spinner">⚙️</span>
|
||||
</div>
|
||||
<h1>Part-DB is under maintenance</h1>
|
||||
<p>We're making things better. This should only take a moment.</p>
|
||||
|
||||
<div class="reason">
|
||||
<strong>{$reason}</strong>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-inner"></div>
|
||||
</div>
|
||||
|
||||
<p class="info">
|
||||
Maintenance mode active since <span class="duration">{$startDateStr}</span><br>
|
||||
<br>
|
||||
Started <span class="duration">{$durationText}</span> ago<br>
|
||||
<small>This page will automatically refresh every 15 seconds.</small>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
32
src/Exceptions/ProviderIDNotSupportedException.php
Normal file
32
src/Exceptions/ProviderIDNotSupportedException.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
class ProviderIDNotSupportedException extends \RuntimeException
|
||||
{
|
||||
public function fromProvider(string $providerKey, string $id): self
|
||||
{
|
||||
return new self(sprintf('The given ID %s is not supported by the provider %s.', $id, $providerKey,));
|
||||
}
|
||||
}
|
||||
@@ -22,17 +22,23 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\AdminPages;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\Attachments\ProjectAttachment;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Services\Attachments\FileTypeFilterTools;
|
||||
use App\Services\LogSystem\EventCommentNeededHelper;
|
||||
use Symfony\Component\Form\CallbackTransformer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Translation\StaticMessage;
|
||||
|
||||
class AttachmentTypeAdminForm extends BaseEntityAdminForm
|
||||
{
|
||||
public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper)
|
||||
public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper, private readonly ElementTypeNameGenerator $elementTypeNameGenerator)
|
||||
{
|
||||
parent::__construct($security, $eventCommentNeededHelper);
|
||||
}
|
||||
@@ -41,6 +47,25 @@ class AttachmentTypeAdminForm extends BaseEntityAdminForm
|
||||
{
|
||||
$is_new = null === $entity->getID();
|
||||
|
||||
|
||||
$choiceLabel = function (string $class) {
|
||||
if (!is_a($class, Attachment::class, true)) {
|
||||
return $class;
|
||||
}
|
||||
return new StaticMessage($this->elementTypeNameGenerator->typeLabelPlural($class::ALLOWED_ELEMENT_CLASS));
|
||||
};
|
||||
|
||||
|
||||
$builder->add('allowed_targets', ChoiceType::class, [
|
||||
'required' => false,
|
||||
'choices' => array_values(Attachment::ORM_DISCRIMINATOR_MAP),
|
||||
'choice_label' => $choiceLabel,
|
||||
'preferred_choices' => [PartAttachment::class, ProjectAttachment::class],
|
||||
'label' => 'attachment_type.edit.allowed_targets',
|
||||
'help' => 'attachment_type.edit.allowed_targets.help',
|
||||
'multiple' => true,
|
||||
]);
|
||||
|
||||
$builder->add('filetype_filter', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'attachment_type.edit.filetype_filter',
|
||||
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Type\AttachmentTypeType;
|
||||
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -67,10 +68,10 @@ class AttachmentFormType extends AbstractType
|
||||
'required' => false,
|
||||
'empty_data' => '',
|
||||
])
|
||||
->add('attachment_type', StructuralEntityType::class, [
|
||||
->add('attachment_type', AttachmentTypeType::class, [
|
||||
'label' => 'attachment.edit.attachment_type',
|
||||
'class' => AttachmentType::class,
|
||||
'disable_not_selectable' => true,
|
||||
'attachment_filter_class' => $options['data_class'] ?? null,
|
||||
'allow_add' => $this->security->isGranted('@attachment_types.create'),
|
||||
]);
|
||||
|
||||
|
||||
@@ -135,6 +135,10 @@ class PartFilterType extends AbstractType
|
||||
'min' => 0,
|
||||
]);
|
||||
|
||||
$builder->add('gtin', TextConstraintType::class, [
|
||||
'label' => 'part.gtin',
|
||||
]);
|
||||
|
||||
$builder->add('measurementUnit', StructuralEntityConstraintType::class, [
|
||||
'label' => 'part.edit.partUnit',
|
||||
'entity_class' => MeasurementUnit::class
|
||||
|
||||
@@ -75,7 +75,8 @@ class ScanDialogType extends AbstractType
|
||||
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
|
||||
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
|
||||
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
|
||||
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp'
|
||||
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp',
|
||||
BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin',
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Part;
|
||||
|
||||
use App\Form\Type\TriStateCheckboxType;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\Supplier;
|
||||
@@ -73,6 +74,11 @@ class OrderdetailType extends AbstractType
|
||||
'label' => 'orderdetails.edit.obsolete',
|
||||
]);
|
||||
|
||||
$builder->add('pricesIncludesVAT', TriStateCheckboxType::class, [
|
||||
'required' => false,
|
||||
'label' => 'orderdetails.edit.prices_includes_vat',
|
||||
]);
|
||||
|
||||
//Add pricedetails after we know the data, so we can set the default currency
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
|
||||
/** @var Orderdetail $orderdetail */
|
||||
|
||||
@@ -43,6 +43,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\LogSystem\EventCommentNeededHelper;
|
||||
use App\Services\LogSystem\EventCommentType;
|
||||
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
@@ -63,6 +64,7 @@ class PartBaseType extends AbstractType
|
||||
protected UrlGeneratorInterface $urlGenerator,
|
||||
protected EventCommentNeededHelper $event_comment_needed_helper,
|
||||
protected IpnSuggestSettings $ipnSuggestSettings,
|
||||
private readonly LocalizationSettings $localizationSettings,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -216,7 +218,13 @@ class PartBaseType extends AbstractType
|
||||
'disable_not_selectable' => true,
|
||||
'label' => 'part.edit.partCustomState',
|
||||
])
|
||||
->add('ipn', TextType::class, $ipnOptions);
|
||||
->add('ipn', TextType::class, $ipnOptions)
|
||||
->add('gtin', TextType::class, [
|
||||
'required' => false,
|
||||
'empty_data' => null,
|
||||
'label' => 'part.gtin',
|
||||
])
|
||||
;
|
||||
|
||||
//Comment section
|
||||
$builder->add('comment', RichTextEditorType::class, [
|
||||
@@ -261,6 +269,9 @@ class PartBaseType extends AbstractType
|
||||
'entity' => $part,
|
||||
]);
|
||||
|
||||
$orderdetailPrototype = new Orderdetail();
|
||||
$orderdetailPrototype->setPricesIncludesVAT($this->localizationSettings->pricesIncludeTaxByDefault);
|
||||
|
||||
//Orderdetails section
|
||||
$builder->add('orderdetails', CollectionType::class, [
|
||||
'entry_type' => OrderdetailType::class,
|
||||
@@ -269,7 +280,7 @@ class PartBaseType extends AbstractType
|
||||
'allow_delete' => true,
|
||||
'label' => false,
|
||||
'by_reference' => false,
|
||||
'prototype_data' => new Orderdetail(),
|
||||
'prototype_data' => $orderdetailPrototype,
|
||||
'entry_options' => [
|
||||
'measurement_unit' => $part->getPartUnit(),
|
||||
],
|
||||
|
||||
@@ -31,6 +31,7 @@ use App\Form\Type\StructuralEntityType;
|
||||
use App\Form\Type\UserSelectType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -110,6 +111,14 @@ class PartLotType extends AbstractType
|
||||
//Do not remove whitespace chars on the beginning and end of the string
|
||||
'trim' => false,
|
||||
]);
|
||||
|
||||
$builder->add('last_stocktake_at', DateTimeType::class, [
|
||||
'label' => 'part_lot.edit.last_stocktake_at',
|
||||
'widget' => 'single_text',
|
||||
'disabled' => !$this->security->isGranted('@parts_stock.stocktake'),
|
||||
'required' => false,
|
||||
'empty_data' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
56
src/Form/Type/AttachmentTypeType.php
Normal file
56
src/Form/Type/AttachmentTypeType.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Form type to select the AttachmentType to use in an attachment form. This is used to filter the available attachment types based on the target class.
|
||||
*/
|
||||
class AttachmentTypeType extends AbstractType
|
||||
{
|
||||
public function getParent(): ?string
|
||||
{
|
||||
return StructuralEntityType::class;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->define('attachment_filter_class')->allowedTypes('null', 'string')->default(null);
|
||||
|
||||
$resolver->setDefault('class', AttachmentType::class);
|
||||
|
||||
$resolver->setDefault('choice_filter', function (Options $options) {
|
||||
if (is_a($options['class'], AttachmentType::class, true) && $options['attachment_filter_class'] !== null) {
|
||||
return static function (?AttachmentType $choice) use ($options) {
|
||||
return $choice?->isAllowedForTarget($options['attachment_filter_class']);
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,13 @@ class DBElementRepository extends EntityRepository
|
||||
return [];
|
||||
}
|
||||
|
||||
//Ensure that all IDs are integers and none is null
|
||||
foreach ($ids as $id) {
|
||||
if (!is_int($id)) {
|
||||
throw new \InvalidArgumentException('Non-integer ID given to findByIDInMatchingOrder: ' . var_export($id, true));
|
||||
}
|
||||
}
|
||||
|
||||
$cache_key = implode(',', $ids);
|
||||
|
||||
//Check if the result is already cached
|
||||
|
||||
@@ -58,13 +58,13 @@ final class PartLotVoter extends Voter
|
||||
{
|
||||
}
|
||||
|
||||
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
|
||||
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move', 'stocktake'];
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $this->helper->resolveUser($token);
|
||||
|
||||
if (in_array($attribute, ['withdraw', 'add', 'move'], true))
|
||||
if (in_array($attribute, ['withdraw', 'add', 'move', 'stocktake'], true))
|
||||
{
|
||||
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote);
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ class FileTypeFilterTools
|
||||
{
|
||||
$filter = trim($filter);
|
||||
|
||||
return $this->cache->get('filter_exts_'.md5($filter), function (ItemInterface $item) use ($filter) {
|
||||
return $this->cache->get('filter_exts_'.hash('xxh3', $filter), function (ItemInterface $item) use ($filter) {
|
||||
$elements = explode(',', $filter);
|
||||
$extensions = [];
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class UserCacheKeyGenerator
|
||||
//If the user is null, then treat it as anonymous user.
|
||||
//When the anonymous user is passed as user then use this path too.
|
||||
if (!($user instanceof User) || User::ID_ANONYMOUS === $user->getID()) {
|
||||
return 'user$_'.User::ID_ANONYMOUS;
|
||||
return 'user$_'.User::ID_ANONYMOUS . '_'.$locale;
|
||||
}
|
||||
|
||||
//Use the unique user id and the locale to generate the key
|
||||
|
||||
@@ -189,7 +189,7 @@ class KiCadHelper
|
||||
"symbolIdStr" => $part->getEdaInfo()->getKicadSymbol() ?? $part->getCategory()?->getEdaInfo()->getKicadSymbol() ?? "",
|
||||
"exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false),
|
||||
"exclude_from_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false),
|
||||
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? true),
|
||||
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false),
|
||||
"fields" => []
|
||||
];
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ class PartMerger implements EntityMergerInterface
|
||||
$this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_number');
|
||||
$this->useOtherValueIfNotEmtpy($target, $other, 'mass');
|
||||
$this->useOtherValueIfNotEmtpy($target, $other, 'ipn');
|
||||
$this->useOtherValueIfNotEmtpy($target, $other, 'gtin');
|
||||
|
||||
//Merge relations to other entities
|
||||
$this->useOtherValueIfNotNull($target, $other, 'manufacturer');
|
||||
@@ -184,4 +185,4 @@ class PartMerger implements EntityMergerInterface
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +274,16 @@ class BOMImporter
|
||||
$entries_by_key = []; // Track entries by name+part combination
|
||||
$mapped_entries = []; // Collect all mapped entries for validation
|
||||
|
||||
// Fetch suppliers once for efficiency
|
||||
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
||||
$supplierSPNKeys = [];
|
||||
$suppliersByName = []; // Map supplier names to supplier objects
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierName = $supplier->getName();
|
||||
$supplierSPNKeys[] = $supplierName . ' SPN';
|
||||
$suppliersByName[$supplierName] = $supplier;
|
||||
}
|
||||
|
||||
foreach ($csv->getRecords() as $offset => $entry) {
|
||||
// Apply field mapping to translate column names
|
||||
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
|
||||
@@ -349,6 +359,41 @@ class BOMImporter
|
||||
}
|
||||
}
|
||||
|
||||
// Try to link existing part based on supplier part number if no Part-DB ID is given
|
||||
if ($part === null) {
|
||||
// Check all available supplier SPN fields
|
||||
foreach ($suppliersByName as $supplierName => $supplier) {
|
||||
$supplier_spn = null;
|
||||
|
||||
if (isset($mapped_entry[$supplierName . ' SPN']) && !empty(trim($mapped_entry[$supplierName . ' SPN']))) {
|
||||
$supplier_spn = trim($mapped_entry[$supplierName . ' SPN']);
|
||||
}
|
||||
|
||||
if ($supplier_spn !== null) {
|
||||
// Query for orderdetails with matching supplier and SPN
|
||||
$orderdetail = $this->entityManager->getRepository(\App\Entity\PriceInformations\Orderdetail::class)
|
||||
->findOneBy([
|
||||
'supplier' => $supplier,
|
||||
'supplierpartnr' => $supplier_spn,
|
||||
]);
|
||||
|
||||
if ($orderdetail !== null && $orderdetail->getPart() !== null) {
|
||||
$part = $orderdetail->getPart();
|
||||
$name = $part->getName(); // Update name with actual part name
|
||||
|
||||
$this->logger->info('Linked BOM entry to existing part via supplier SPN', [
|
||||
'supplier' => $supplierName,
|
||||
'supplier_spn' => $supplier_spn,
|
||||
'part_id' => $part->getID(),
|
||||
'part_name' => $part->getName(),
|
||||
]);
|
||||
|
||||
break; // Stop searching once a match is found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create unique key for this entry (name + part ID)
|
||||
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
|
||||
|
||||
@@ -400,9 +445,14 @@ class BOMImporter
|
||||
if (isset($mapped_entry['Manufacturer'])) {
|
||||
$comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer'];
|
||||
}
|
||||
if (isset($mapped_entry['LCSC'])) {
|
||||
$comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC'];
|
||||
|
||||
// Add supplier part numbers dynamically
|
||||
foreach ($supplierSPNKeys as $spnKey) {
|
||||
if (isset($mapped_entry[$spnKey]) && !empty($mapped_entry[$spnKey])) {
|
||||
$comment_parts[] = $spnKey . ': ' . $mapped_entry[$spnKey];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($mapped_entry['Supplier and ref'])) {
|
||||
$comment_parts[] = $mapped_entry['Supplier and ref'];
|
||||
}
|
||||
|
||||
@@ -39,10 +39,10 @@ class PKImportHelper
|
||||
* Existing users and groups are not purged.
|
||||
* This is needed to avoid ID collisions.
|
||||
*/
|
||||
public function purgeDatabaseForImport(): void
|
||||
public function purgeDatabaseForImport(?EntityManagerInterface $entityManager = null, array $excluded_tables = ['users', 'groups', 'u2f_keys', 'internal', 'migration_versions']): void
|
||||
{
|
||||
//We use the ResetAutoIncrementORMPurger to reset the auto increment values of the tables. Also it normalizes table names before checking for exclusion.
|
||||
$purger = new ResetAutoIncrementORMPurger($this->em, ['users', 'groups', 'u2f_keys', 'internal', 'migration_versions']);
|
||||
$purger = new ResetAutoIncrementORMPurger($entityManager ?? $this->em, $excluded_tables);
|
||||
$purger->purge();
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class PartDetailDTO extends SearchResultDTO
|
||||
?ManufacturingStatus $manufacturing_status = null,
|
||||
?string $provider_url = null,
|
||||
?string $footprint = null,
|
||||
?string $gtin = null,
|
||||
public readonly ?string $notes = null,
|
||||
/** @var FileDTO[]|null */
|
||||
public readonly ?array $datasheets = null,
|
||||
@@ -68,6 +69,7 @@ class PartDetailDTO extends SearchResultDTO
|
||||
manufacturing_status: $manufacturing_status,
|
||||
provider_url: $provider_url,
|
||||
footprint: $footprint,
|
||||
gtin: $gtin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ readonly class PriceDTO
|
||||
public string $price,
|
||||
/** @var string The currency of the used ISO code of this price detail */
|
||||
public ?string $currency_iso_code,
|
||||
/** @var bool If the price includes tax */
|
||||
/** @var bool If the price includes tax
|
||||
* @deprecated Use the prices_include_vat property of the PurchaseInfoDTO instead, as this property is not reliable if there are multiple prices with different values for includes_tax
|
||||
*/
|
||||
public ?bool $includes_tax = true,
|
||||
/** @var float the price related quantity */
|
||||
public ?float $price_related_quantity = 1.0,
|
||||
|
||||
@@ -29,6 +29,9 @@ namespace App\Services\InfoProviderSystem\DTOs;
|
||||
*/
|
||||
readonly class PurchaseInfoDTO
|
||||
{
|
||||
/** @var bool|null If the prices contain VAT or not. Null if state is unknown. */
|
||||
public ?bool $prices_include_vat;
|
||||
|
||||
public function __construct(
|
||||
public string $distributor_name,
|
||||
public string $order_number,
|
||||
@@ -36,6 +39,7 @@ readonly class PurchaseInfoDTO
|
||||
public array $prices,
|
||||
/** @var string|null An url to the product page of the vendor */
|
||||
public ?string $product_url = null,
|
||||
?bool $prices_include_vat = null,
|
||||
)
|
||||
{
|
||||
//Ensure that the prices are PriceDTO instances
|
||||
@@ -44,5 +48,17 @@ readonly class PurchaseInfoDTO
|
||||
throw new \InvalidArgumentException('The prices array must only contain PriceDTO instances');
|
||||
}
|
||||
}
|
||||
|
||||
//If no prices_include_vat information is given, try to deduct it from the prices
|
||||
if ($prices_include_vat === null) {
|
||||
$vatValues = array_unique(array_map(fn(PriceDTO $price) => $price->includes_tax, $this->prices));
|
||||
if (count($vatValues) === 1) {
|
||||
$this->prices_include_vat = $vatValues[0]; //Use the value of the prices if they are all the same
|
||||
} else {
|
||||
$this->prices_include_vat = null; //If there are different values for the prices, we cannot determine if the prices include VAT or not
|
||||
}
|
||||
} else {
|
||||
$this->prices_include_vat = $prices_include_vat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ class SearchResultDTO
|
||||
public readonly ?string $provider_url = null,
|
||||
/** @var string|null A footprint representation of the providers page */
|
||||
public readonly ?string $footprint = null,
|
||||
/** @var string|null The GTIN / EAN of the part */
|
||||
public readonly ?string $gtin = null,
|
||||
)
|
||||
{
|
||||
if ($preview_image_url !== null) {
|
||||
@@ -90,6 +92,7 @@ class SearchResultDTO
|
||||
'manufacturing_status' => $this->manufacturing_status?->value,
|
||||
'provider_url' => $this->provider_url,
|
||||
'footprint' => $this->footprint,
|
||||
'gtin' => $this->gtin,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -112,6 +115,7 @@ class SearchResultDTO
|
||||
manufacturing_status: isset($data['manufacturing_status']) ? ManufacturingStatus::tryFrom($data['manufacturing_status']) : null,
|
||||
provider_url: $data['provider_url'] ?? null,
|
||||
footprint: $data['footprint'] ?? null,
|
||||
gtin: $data['gtin'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@ final class DTOtoEntityConverter
|
||||
$entity->setPrice($dto->getPriceAsBigDecimal());
|
||||
$entity->setPriceRelatedQuantity($dto->price_related_quantity);
|
||||
|
||||
//Currency TODO
|
||||
if ($dto->currency_iso_code !== null) {
|
||||
$entity->setCurrency($this->getCurrency($dto->currency_iso_code));
|
||||
} else {
|
||||
@@ -117,6 +116,8 @@ final class DTOtoEntityConverter
|
||||
$entity->addPricedetail($this->convertPrice($price));
|
||||
}
|
||||
|
||||
$entity->setPricesIncludesVAT($dto->prices_include_vat);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
@@ -175,6 +176,8 @@ final class DTOtoEntityConverter
|
||||
$entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET);
|
||||
$entity->setManufacturerProductURL($dto->manufacturer_product_url ?? '');
|
||||
|
||||
$entity->setGtin($dto->gtin);
|
||||
|
||||
//Set the provider reference on the part
|
||||
$entity->setProviderReference(InfoProviderReference::fromPartDTO($dto));
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ declare(strict_types=1);
|
||||
namespace App\Services\InfoProviderSystem;
|
||||
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use App\Services\InfoProviderSystem\Providers\URLHandlerInfoProviderInterface;
|
||||
|
||||
/**
|
||||
* This class keeps track of all registered info providers and allows to find them by their key
|
||||
@@ -47,6 +48,8 @@ final class ProviderRegistry
|
||||
*/
|
||||
private array $providers_disabled = [];
|
||||
|
||||
private array $providers_by_domain = [];
|
||||
|
||||
/**
|
||||
* @var bool Whether the registry has been initialized
|
||||
*/
|
||||
@@ -78,6 +81,14 @@ final class ProviderRegistry
|
||||
$this->providers_by_name[$key] = $provider;
|
||||
if ($provider->isActive()) {
|
||||
$this->providers_active[$key] = $provider;
|
||||
if ($provider instanceof URLHandlerInfoProviderInterface) {
|
||||
foreach ($provider->getHandledDomains() as $domain) {
|
||||
if (isset($this->providers_by_domain[$domain])) {
|
||||
throw new \LogicException("Domain $domain is already handled by another provider");
|
||||
}
|
||||
$this->providers_by_domain[$domain] = $provider;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->providers_disabled[$key] = $provider;
|
||||
}
|
||||
@@ -139,4 +150,29 @@ final class ProviderRegistry
|
||||
|
||||
return $this->providers_disabled;
|
||||
}
|
||||
}
|
||||
|
||||
public function getProviderHandlingDomain(string $domain): (InfoProviderInterface&URLHandlerInfoProviderInterface)|null
|
||||
{
|
||||
if (!$this->initialized) {
|
||||
$this->initStructures();
|
||||
}
|
||||
|
||||
//Check if the domain is directly existing:
|
||||
if (isset($this->providers_by_domain[$domain])) {
|
||||
return $this->providers_by_domain[$domain];
|
||||
}
|
||||
|
||||
//Otherwise check for subdomains:
|
||||
$parts = explode('.', $domain);
|
||||
while (count($parts) > 2) {
|
||||
array_shift($parts);
|
||||
$check_domain = implode('.', $parts);
|
||||
if (isset($this->providers_by_domain[$check_domain])) {
|
||||
return $this->providers_by_domain[$check_domain];
|
||||
}
|
||||
}
|
||||
|
||||
//If we found nothing, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
639
src/Services/InfoProviderSystem/Providers/BuerklinProvider.php
Normal file
639
src/Services/InfoProviderSystem/Providers/BuerklinProvider.php
Normal file
@@ -0,0 +1,639 @@
|
||||
<?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)
|
||||
* Copyright (C) 2025 Marc Kreidler (https://github.com/mkne)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\BuerklinSettings;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class BuerklinProvider implements BatchInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin';
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Buerklin';
|
||||
|
||||
private const CACHE_TTL = 600;
|
||||
/**
|
||||
* Local in-request cache to avoid hitting the PSR cache repeatedly for the same product.
|
||||
* @var array<string, array>
|
||||
*/
|
||||
private array $productCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly CacheItemPoolInterface $partInfoCache,
|
||||
private readonly BuerklinSettings $settings,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest OAuth token for the Buerklin API, or creates a new one if none is available
|
||||
* TODO: Rework this to use the OAuth token manager system in the database...
|
||||
* @return string
|
||||
*/
|
||||
private function getToken(): string
|
||||
{
|
||||
// Cache token to avoid hammering the auth server on every request
|
||||
$cacheKey = 'buerklin.oauth.token';
|
||||
$item = $this->partInfoCache->getItem($cacheKey);
|
||||
|
||||
if ($item->isHit()) {
|
||||
$token = $item->get();
|
||||
if (is_string($token) && $token !== '') {
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
||||
// Buerklin OAuth2 password grant (ROPC)
|
||||
$resp = $this->client->request('POST', 'https://www.buerklin.com/authorizationserver/oauth/token/', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
],
|
||||
'body' => [
|
||||
'grant_type' => 'password',
|
||||
'client_id' => $this->settings->clientId,
|
||||
'client_secret' => $this->settings->secret,
|
||||
'username' => $this->settings->username,
|
||||
'password' => $this->settings->password,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = $resp->toArray(false);
|
||||
|
||||
if (!isset($data['access_token'])) {
|
||||
throw new \RuntimeException(
|
||||
'Invalid token response from Buerklin: HTTP ' . $resp->getStatusCode() . ' body=' . $resp->getContent(false)
|
||||
);
|
||||
}
|
||||
|
||||
$token = (string) $data['access_token'];
|
||||
|
||||
// Cache for (expires_in - 30s) if available
|
||||
$ttl = 300;
|
||||
if (isset($data['expires_in']) && is_numeric($data['expires_in'])) {
|
||||
$ttl = max(60, (int) $data['expires_in'] - 30);
|
||||
}
|
||||
|
||||
$item->set($token);
|
||||
$item->expiresAfter($ttl);
|
||||
$this->partInfoCache->save($item);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function getDefaultQueryParams(): array
|
||||
{
|
||||
return [
|
||||
'curr' => $this->settings->currency ?: 'EUR',
|
||||
'language' => $this->settings->language ?: 'en',
|
||||
];
|
||||
}
|
||||
|
||||
private function getProduct(string $code): array
|
||||
{
|
||||
$code = strtoupper(trim($code));
|
||||
if ($code === '') {
|
||||
throw new \InvalidArgumentException('Product code must not be empty.');
|
||||
}
|
||||
|
||||
$cacheKey = sprintf(
|
||||
'buerklin.product.%s',
|
||||
md5($code . '|' . $this->settings->language . '|' . $this->settings->currency)
|
||||
);
|
||||
|
||||
if (isset($this->productCache[$cacheKey])) {
|
||||
return $this->productCache[$cacheKey];
|
||||
}
|
||||
|
||||
$item = $this->partInfoCache->getItem($cacheKey);
|
||||
if ($item->isHit() && is_array($cached = $item->get())) {
|
||||
return $this->productCache[$cacheKey] = $cached;
|
||||
}
|
||||
|
||||
$product = $this->makeAPICall('/products/' . rawurlencode($code) . '/');
|
||||
|
||||
$item->set($product);
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
$this->partInfoCache->save($item);
|
||||
|
||||
return $this->productCache[$cacheKey] = $product;
|
||||
}
|
||||
|
||||
private function makeAPICall(string $endpoint, array $queryParams = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->client->request('GET', self::ENDPOINT_URL . $endpoint, [
|
||||
'auth_bearer' => $this->getToken(),
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
'query' => array_merge($this->getDefaultQueryParams(), $queryParams),
|
||||
]);
|
||||
|
||||
return $response->toArray();
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException("Buerklin API request failed: " .
|
||||
"Endpoint: " . $endpoint .
|
||||
"Token: [redacted] " .
|
||||
"QueryParams: " . json_encode($queryParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . " " .
|
||||
"Exception message: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Buerklin',
|
||||
'description' => 'This provider uses the Buerklin API to search for parts.',
|
||||
'url' => 'https://www.buerklin.com/',
|
||||
'disabled_help' => 'Configure the API Client ID, Secret, Username and Password provided by Buerklin in the provider settings to enable.',
|
||||
'settings_class' => BuerklinSettings::class
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'buerklin';
|
||||
}
|
||||
|
||||
// This provider is considered active if settings are present
|
||||
public function isActive(): bool
|
||||
{
|
||||
// The client credentials and user credentials must be set
|
||||
return $this->settings->clientId !== null && $this->settings->clientId !== ''
|
||||
&& $this->settings->secret !== null && $this->settings->secret !== ''
|
||||
&& $this->settings->username !== null && $this->settings->username !== ''
|
||||
&& $this->settings->password !== null && $this->settings->password !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a field by removing any HTML tags and other unwanted characters
|
||||
* @param string|null $field
|
||||
* @return string|null
|
||||
*/
|
||||
private function sanitizeField(?string $field): ?string
|
||||
{
|
||||
if ($field === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strip_tags($field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a deserialized JSON object of the product and returns a PartDetailDTO
|
||||
* @param array $product
|
||||
* @return PartDetailDTO
|
||||
*/
|
||||
private function getPartDetail(array $product): PartDetailDTO
|
||||
{
|
||||
// If this is a search-result object, it may not contain prices/features/images -> reload full details.
|
||||
if ((!isset($product['price']) && !isset($product['volumePrices'])) && isset($product['code'])) {
|
||||
try {
|
||||
$product = $this->getProduct((string) $product['code']);
|
||||
} catch (\Throwable $e) {
|
||||
// If reload fails, keep the partial product data and continue.
|
||||
}
|
||||
}
|
||||
|
||||
// Extract images from API response
|
||||
$productImages = $this->getProductImages($product['images'] ?? null);
|
||||
|
||||
// Set preview image
|
||||
$preview = $productImages[0]->url ?? null;
|
||||
|
||||
// Extract features (parameters) from classifications[0].features of Buerklin JSON response
|
||||
$features = $product['classifications'][0]['features'] ?? [];
|
||||
|
||||
// Feature parameters (from classifications->features)
|
||||
$featureParams = $this->attributesToParameters($features, ''); // leave group empty for normal parameters
|
||||
|
||||
// Compliance parameters (from top-level fields like RoHS/SVHC/…)
|
||||
$complianceParams = $this->complianceToParameters($product, 'Compliance');
|
||||
|
||||
// Merge all parameters
|
||||
$allParams = array_merge($featureParams, $complianceParams);
|
||||
|
||||
// Assign footprint: "Design" (en) / "Bauform" (de) / "Enclosure" (en) / "Gehäuse" (de)
|
||||
$footprint = null;
|
||||
if (is_array($features)) {
|
||||
foreach ($features as $feature) {
|
||||
$name = $feature['name'] ?? null;
|
||||
if ($name === 'Design' || $name === 'Bauform' || $name === 'Enclosure' || $name === 'Gehäuse') {
|
||||
$footprint = $feature['featureValues'][0]['value'] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prices: prefer volumePrices, fallback to single price
|
||||
$code = (string) ($product['orderNumber'] ?? $product['code'] ?? '');
|
||||
$prices = $product['volumePrices'] ?? null;
|
||||
|
||||
if (!is_array($prices) || count($prices) === 0) {
|
||||
$pVal = $product['price']['value'] ?? null;
|
||||
$pCur = $product['price']['currencyIso'] ?? ($this->settings->currency ?: 'EUR');
|
||||
|
||||
if (is_numeric($pVal)) {
|
||||
$prices = [
|
||||
[
|
||||
'minQuantity' => 1,
|
||||
'value' => (float) $pVal,
|
||||
'currencyIso' => (string) $pCur,
|
||||
]
|
||||
];
|
||||
} else {
|
||||
$prices = [];
|
||||
}
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: (string) ($product['code'] ?? $code),
|
||||
|
||||
name: (string) ($product['manufacturerProductId'] ?? $code),
|
||||
description: $this->sanitizeField($product['description'] ?? null),
|
||||
|
||||
category: $this->sanitizeField($product['classifications'][0]['name'] ?? ($product['categories'][0]['name'] ?? null)),
|
||||
manufacturer: $this->sanitizeField($product['manufacturer'] ?? null),
|
||||
mpn: $this->sanitizeField($product['manufacturerProductId'] ?? null),
|
||||
|
||||
preview_image_url: $preview,
|
||||
manufacturing_status: null,
|
||||
|
||||
provider_url: $this->getProductShortURL((string) ($product['code'] ?? $code)),
|
||||
footprint: $footprint,
|
||||
|
||||
datasheets: null, // not found in JSON response, the Buerklin website however has links to datasheets
|
||||
images: $productImages,
|
||||
|
||||
parameters: $allParams,
|
||||
|
||||
vendor_infos: $this->pricesToVendorInfo(
|
||||
sku: $code,
|
||||
url: $this->getProductShortURL($code),
|
||||
prices: $prices
|
||||
),
|
||||
|
||||
mass: $product['weight'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the price array to a VendorInfoDTO array to be used in the PartDetailDTO
|
||||
* @param string $sku
|
||||
* @param string $url
|
||||
* @param array $prices
|
||||
* @return array
|
||||
*/
|
||||
private function pricesToVendorInfo(string $sku, string $url, array $prices): array
|
||||
{
|
||||
$priceDTOs = array_map(function ($price) {
|
||||
$val = $price['value'] ?? null;
|
||||
$valStr = is_numeric($val)
|
||||
? number_format((float) $val, 6, '.', '') // 6 decimal places, trailing zeros are fine
|
||||
: (string) $val;
|
||||
|
||||
// Optional: softly trim unnecessary trailing zeros (e.g. 75.550000 -> 75.55)
|
||||
$valStr = rtrim(rtrim($valStr, '0'), '.');
|
||||
|
||||
return new PriceDTO(
|
||||
minimum_discount_amount: (float) ($price['minQuantity'] ?? 1),
|
||||
price: $valStr,
|
||||
currency_iso_code: (string) ($price['currencyIso'] ?? $this->settings->currency ?? 'EUR'),
|
||||
includes_tax: false
|
||||
);
|
||||
}, $prices);
|
||||
|
||||
return [
|
||||
new PurchaseInfoDTO(
|
||||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $sku,
|
||||
prices: $priceDTOs,
|
||||
product_url: $url,
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a valid Buerklin product short URL from product code
|
||||
* @param string $product_code
|
||||
* @return string
|
||||
*/
|
||||
private function getProductShortURL(string $product_code): string
|
||||
{
|
||||
return 'https://www.buerklin.com/' . $this->settings->language . '/p/' . $product_code . '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deduplicated list of product images as FileDTOs.
|
||||
*
|
||||
* - takes only real image arrays (with 'url' field)
|
||||
* - makes relative URLs absolute
|
||||
* - deduplicates using URL
|
||||
* - prefers 'zoom' format, then 'product' format, then all others
|
||||
*
|
||||
* @param array|null $images
|
||||
* @return \App\Services\InfoProviderSystem\DTOs\FileDTO[]
|
||||
*/
|
||||
private function getProductImages(?array $images): array
|
||||
{
|
||||
if (!is_array($images)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1) Only real image entries with URL
|
||||
$imgs = array_values(array_filter($images, fn($i) => is_array($i) && !empty($i['url'])));
|
||||
|
||||
// 2) Prefer zoom images
|
||||
$zoom = array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'zoom'));
|
||||
$chosen = count($zoom) > 0
|
||||
? $zoom
|
||||
: array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'product'));
|
||||
|
||||
// 3) If still none, take all
|
||||
if (count($chosen) === 0) {
|
||||
$chosen = $imgs;
|
||||
}
|
||||
|
||||
// 4) Deduplicate by URL (after making absolute)
|
||||
$byUrl = [];
|
||||
foreach ($chosen as $img) {
|
||||
$url = (string) $img['url'];
|
||||
|
||||
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
|
||||
$url = 'https://www.buerklin.com' . $url;
|
||||
}
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$byUrl[$url] = $url;
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn($url) => new FileDTO($url),
|
||||
array_values($byUrl)
|
||||
);
|
||||
}
|
||||
|
||||
private function attributesToParameters(array $features, ?string $group = null): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($features as $f) {
|
||||
if (!is_array($f)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $f['name'] ?? null;
|
||||
if (!is_string($name) || trim($name) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vals = [];
|
||||
foreach (($f['featureValues'] ?? []) as $fv) {
|
||||
if (is_array($fv) && isset($fv['value']) && is_string($fv['value']) && trim($fv['value']) !== '') {
|
||||
$vals[] = trim($fv['value']);
|
||||
}
|
||||
}
|
||||
if (empty($vals)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multiple values: join with comma
|
||||
$value = implode(', ', array_values(array_unique($vals)));
|
||||
|
||||
// Unit/symbol from Buerklin feature
|
||||
$unit = $f['featureUnit']['symbol'] ?? null;
|
||||
if (!is_string($unit) || trim($unit) === '') {
|
||||
$unit = null;
|
||||
}
|
||||
|
||||
// ParameterDTO parses value field (handles value + unit)
|
||||
$out[] = ParameterDTO::parseValueField(
|
||||
name: $name,
|
||||
value: $value,
|
||||
unit: $unit,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
}
|
||||
|
||||
// Deduplicate by name
|
||||
$byName = [];
|
||||
foreach ($out as $p) {
|
||||
$byName[$p->name] ??= $p;
|
||||
}
|
||||
|
||||
return array_values($byName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PartDetailDTO[]
|
||||
*/
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$keyword = strtoupper(trim($keyword));
|
||||
if ($keyword === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$response = $this->makeAPICall('/products/search/', [
|
||||
'pageSize' => 50,
|
||||
'currentPage' => 0,
|
||||
'query' => $keyword,
|
||||
'sort' => 'relevance',
|
||||
]);
|
||||
|
||||
$products = $response['products'] ?? [];
|
||||
|
||||
// Normal case: products found in search results
|
||||
if (is_array($products) && !empty($products)) {
|
||||
return array_map(fn($p) => $this->getPartDetail($p), $products);
|
||||
}
|
||||
|
||||
// Fallback: try direct lookup by code
|
||||
try {
|
||||
$product = $this->getProduct($keyword);
|
||||
return [$this->getPartDetail($product)];
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
// Detail endpoint is /products/{code}/
|
||||
$response = $this->getProduct($id);
|
||||
|
||||
return $this->getPartDetail($response);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
//ProviderCapabilities::DATASHEET, // currently not implemented
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
];
|
||||
}
|
||||
|
||||
private function complianceToParameters(array $product, ?string $group = 'Compliance'): array
|
||||
{
|
||||
$params = [];
|
||||
|
||||
$add = function (string $name, $value) use (&$params, $group) {
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
$value = $value ? 'Yes' : 'No';
|
||||
} elseif (is_array($value) || is_object($value)) {
|
||||
// Avoid dumping large or complex structures
|
||||
return;
|
||||
} else {
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$params[] = ParameterDTO::parseValueField(
|
||||
name: $name,
|
||||
value: (string) $value,
|
||||
unit: null,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
};
|
||||
|
||||
$add('RoHS conform', $product['labelRoHS'] ?? null); // "yes"/"no"
|
||||
|
||||
$rawRoHsDate = $product['dateRoHS'] ?? null;
|
||||
// Try to parse and reformat date to Y-m-d (do not use language-dependent formats)
|
||||
if (is_string($rawRoHsDate) && $rawRoHsDate !== '') {
|
||||
try {
|
||||
$dt = new \DateTimeImmutable($rawRoHsDate);
|
||||
$formatted = $dt->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
$formatted = $rawRoHsDate;
|
||||
}
|
||||
// Always use the same parameter name (do not use language-dependent names)
|
||||
$add('RoHS date', $formatted);
|
||||
}
|
||||
$add('SVHC free', $product['SVHC'] ?? null); // bool
|
||||
$add('Hazardous good', $product['hazardousGood'] ?? null); // bool
|
||||
$add('Hazardous materials', $product['hazardousMaterials'] ?? null); // bool
|
||||
|
||||
$add('Country of origin', $product['countryOfOrigin'] ?? null);
|
||||
// Customs tariff code must always be stored as string, otherwise "85411000" may be stored as "8.5411e+7"
|
||||
if (isset($product['articleCustomsCode'])) {
|
||||
// Raw value as string
|
||||
$codeRaw = (string) $product['articleCustomsCode'];
|
||||
|
||||
// Optionally keep only digits (in case of spaces or other characters)
|
||||
$code = preg_replace('/\D/', '', $codeRaw) ?? $codeRaw;
|
||||
$code = trim($code);
|
||||
|
||||
if ($code !== '') {
|
||||
$params[] = new ParameterDTO(
|
||||
name: 'Customs code',
|
||||
value_text: $code,
|
||||
value_typ: null,
|
||||
value_min: null,
|
||||
value_max: null,
|
||||
unit: null,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $keywords
|
||||
* @return array<string, SearchResultDTO[]>
|
||||
*/
|
||||
public function searchByKeywordsBatch(array $keywords): array
|
||||
{
|
||||
/** @var array<string, SearchResultDTO[]> $results */
|
||||
$results = [];
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$keyword = strtoupper(trim((string) $keyword));
|
||||
if ($keyword === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reuse existing single search -> returns PartDetailDTO[]
|
||||
/** @var PartDetailDTO[] $partDetails */
|
||||
$partDetails = $this->searchByKeyword($keyword);
|
||||
|
||||
// Convert to SearchResultDTO[]
|
||||
$results[$keyword] = array_map(
|
||||
fn(PartDetailDTO $detail) => $this->convertPartDetailToSearchResult($detail),
|
||||
$partDetails
|
||||
);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a PartDetailDTO into a SearchResultDTO for bulk search.
|
||||
*/
|
||||
private function convertPartDetailToSearchResult(PartDetailDTO $detail): SearchResultDTO
|
||||
{
|
||||
return new SearchResultDTO(
|
||||
provider_key: $detail->provider_key,
|
||||
provider_id: $detail->provider_id,
|
||||
name: $detail->name,
|
||||
description: $detail->description ?? '',
|
||||
category: $detail->category ?? null,
|
||||
manufacturer: $detail->manufacturer ?? null,
|
||||
mpn: $detail->mpn ?? null,
|
||||
preview_image_url: $detail->preview_image_url ?? null,
|
||||
manufacturing_status: $detail->manufacturing_status ?? null,
|
||||
provider_url: $detail->provider_url ?? null,
|
||||
footprint: $detail->footprint ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
347
src/Services/InfoProviderSystem/Providers/ConradProvider.php
Normal file
347
src/Services/InfoProviderSystem/Providers/ConradProvider.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\ConradSettings;
|
||||
use App\Settings\InfoProviderSystem\ConradShopIDs;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const SEARCH_ENDPOINT = '/search/1/v3/facetSearch';
|
||||
public const DISTRIBUTOR_NAME = 'Conrad';
|
||||
|
||||
private HttpClientInterface $httpClient;
|
||||
|
||||
public function __construct( HttpClientInterface $httpClient, private ConradSettings $settings)
|
||||
{
|
||||
//We want everything in JSON
|
||||
$this->httpClient = $httpClient->withOptions([
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Conrad',
|
||||
'description' => 'Retrieves part information from conrad.de',
|
||||
'url' => 'https://www.conrad.de/',
|
||||
'disabled_help' => 'Set API key in settings',
|
||||
'settings_class' => ConradSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'conrad';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return !empty($this->settings->apiKey);
|
||||
}
|
||||
|
||||
private function getProductUrl(string $productId): string
|
||||
{
|
||||
return 'https://' . $this->settings->shopID->getDomain() . '/' . $this->settings->shopID->getLanguage() . '/p/' . $productId;
|
||||
}
|
||||
|
||||
private function getFootprintFromTechnicalDetails(array $technicalDetails): ?string
|
||||
{
|
||||
foreach ($technicalDetails as $detail) {
|
||||
if ($detail['name'] === 'ATT_LOV_HOUSING_SEMICONDUCTORS') {
|
||||
return $detail['values'][0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/'
|
||||
. $this->settings->shopID->getDomainEnd() . '/' . $this->settings->shopID->getLanguage()
|
||||
. '/' . $this->settings->shopID->getCustomerType();
|
||||
|
||||
$response = $this->httpClient->request('POST', $url, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
],
|
||||
'json' => [
|
||||
'query' => $keyword,
|
||||
'size' => 50,
|
||||
'sort' => [["field"=>"_score","order"=>"desc"]],
|
||||
],
|
||||
]);
|
||||
|
||||
$out = [];
|
||||
$results = $response->toArray();
|
||||
|
||||
foreach($results['hits'] as $result) {
|
||||
|
||||
$out[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $result['productId'],
|
||||
name: $result['manufacturerId'] ?? $result['productId'],
|
||||
description: $result['title'] ?? '',
|
||||
manufacturer: $result['brand']['name'] ?? null,
|
||||
mpn: $result['manufacturerId'] ?? null,
|
||||
preview_image_url: $result['image'] ?? null,
|
||||
provider_url: $this->getProductUrl($result['productId']),
|
||||
footprint: $this->getFootprintFromTechnicalDetails($result['technicalDetails'] ?? []),
|
||||
gtin: $result['ean'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function getFootprintFromTechnicalAttributes(array $technicalDetails): ?string
|
||||
{
|
||||
foreach ($technicalDetails as $detail) {
|
||||
if ($detail['attributeID'] === 'ATT.LOV.HOUSING_SEMICONDUCTORS') {
|
||||
return $detail['values'][0]['value'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $technicalAttributes
|
||||
* @return array<ParameterDTO>
|
||||
*/
|
||||
private function technicalAttributesToParameters(array $technicalAttributes): array
|
||||
{
|
||||
return array_map(static function (array $p) {
|
||||
if (count($p['values']) === 1) { //Single value attribute
|
||||
if (array_key_exists('unit', $p['values'][0])) {
|
||||
return ParameterDTO::parseValueField( //With unit
|
||||
name: $p['attributeName'],
|
||||
value: $p['values'][0]['value'],
|
||||
unit: $p['values'][0]['unit']['name'],
|
||||
);
|
||||
}
|
||||
|
||||
return ParameterDTO::parseValueIncludingUnit(
|
||||
name: $p['attributeName'],
|
||||
value: $p['values'][0]['value'],
|
||||
);
|
||||
}
|
||||
|
||||
if (count($p['values']) === 2) { //Multi value attribute (e.g. min/max)
|
||||
$value = $p['values'][0]['value'] ?? null;
|
||||
$value2 = $p['values'][1]['value'] ?? null;
|
||||
$unit = $p['values'][0]['unit']['name'] ?? '';
|
||||
$unit2 = $p['values'][1]['unit']['name'] ?? '';
|
||||
if ($unit === $unit2 && is_numeric($value) && is_numeric($value2)) {
|
||||
if (array_key_exists('unit', $p['values'][0])) { //With unit
|
||||
return new ParameterDTO(
|
||||
name: $p['attributeName'],
|
||||
value_min: (float)$value,
|
||||
value_max: (float)$value2,
|
||||
unit: $unit,
|
||||
);
|
||||
}
|
||||
|
||||
return new ParameterDTO(
|
||||
name: $p['attributeName'],
|
||||
value_min: (float)$value,
|
||||
value_max: (float)$value2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// fallback implementation
|
||||
$values = implode(", ", array_map(fn($q) =>
|
||||
array_key_exists('unit', $q) ? $q['value']." ". ($q['unit']['name'] ?? $q['unit']) : $q['value']
|
||||
, $p['values']));
|
||||
return ParameterDTO::parseValueIncludingUnit(
|
||||
name: $p['attributeName'],
|
||||
value: $values,
|
||||
);
|
||||
}, $technicalAttributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $productMedia
|
||||
* @return array<FileDTO>
|
||||
*/
|
||||
public function productMediaToDatasheets(array $productMedia): array
|
||||
{
|
||||
$files = [];
|
||||
foreach ($productMedia['manuals'] as $manual) {
|
||||
//Filter out unwanted languages
|
||||
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = new FileDTO($manual['fullUrl'], $manual['title'] . ' (' . $manual['language'] . ')');
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Queries prices for a given product ID. It makes a POST request to the Conrad API
|
||||
* @param string $productId
|
||||
* @return PurchaseInfoDTO
|
||||
*/
|
||||
private function queryPrices(string $productId): PurchaseInfoDTO
|
||||
{
|
||||
$priceQueryURL = $this->settings->shopID->getAPIRoot() . '/price-availability/4/'
|
||||
. $this->settings->shopID->getShopID() . '/facade';
|
||||
|
||||
$response = $this->httpClient->request('POST', $priceQueryURL, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
'overrideCalculationSchema' => $this->settings->includeVAT ? 'GROSS' : 'NET'
|
||||
],
|
||||
'json' => [
|
||||
'ns:inputArticleItemList' => [
|
||||
"#namespaces" => [
|
||||
"ns" => "http://www.conrad.de/ccp/basit/service/article/priceandavailabilityservice/api"
|
||||
],
|
||||
'articles' => [
|
||||
[
|
||||
"articleID" => $productId,
|
||||
"calculatePrice" => true,
|
||||
"checkAvailability" => true,
|
||||
],
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$result = $response->toArray();
|
||||
|
||||
$priceInfo = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['price'] ?? [];
|
||||
$price = $priceInfo['price'] ?? "0.0";
|
||||
$currency = $priceInfo['currency'] ?? "EUR";
|
||||
$includesVat = !$priceInfo['isGrossAmount'] || $priceInfo['isGrossAmount'] === "true";
|
||||
$minOrderAmount = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['availabilityStatus']['minimumOrderQuantity'] ?? 1;
|
||||
|
||||
$prices = [];
|
||||
foreach ($priceInfo['priceScale'] ?? [] as $priceScale) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: max($priceScale['scaleFrom'], $minOrderAmount),
|
||||
price: (string)$priceScale['pricePerUnit'],
|
||||
currency_iso_code: $currency,
|
||||
includes_tax: $includesVat
|
||||
);
|
||||
}
|
||||
if (empty($prices)) { //Fallback if no price scales are defined
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: $minOrderAmount,
|
||||
price: (string)$price,
|
||||
currency_iso_code: $currency,
|
||||
includes_tax: $includesVat
|
||||
);
|
||||
}
|
||||
|
||||
return new PurchaseInfoDTO(
|
||||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $productId,
|
||||
prices: $prices,
|
||||
product_url: $this->getProductUrl($productId)
|
||||
);
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
$productInfoURL = $this->settings->shopID->getAPIRoot() . '/product/1/service/' . $this->settings->shopID->getShopID()
|
||||
. '/product/' . $id;
|
||||
|
||||
$response = $this->httpClient->request('GET', $productInfoURL, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $data['shortProductNumber'],
|
||||
name: $data['productFullInformation']['manufacturer']['name'] ?? $data['productFullInformation']['manufacturer']['id'] ?? $data['shortProductNumber'],
|
||||
description: $data['productShortInformation']['title'] ?? '',
|
||||
category: $data['productShortInformation']['articleGroupName'] ?? null,
|
||||
manufacturer: $data['brand']['displayName'] !== null ? preg_replace("/[\u{2122}\u{00ae}]/", "", $data['brand']['displayName']) : null, //Replace ™ and ® symbols
|
||||
mpn: $data['productFullInformation']['manufacturer']['id'] ?? null,
|
||||
preview_image_url: $data['productShortInformation']['mainImage']['imageUrl'] ?? null,
|
||||
provider_url: $this->getProductUrl($data['shortProductNumber']),
|
||||
footprint: $this->getFootprintFromTechnicalAttributes($data['productFullInformation']['technicalAttributes'] ?? []),
|
||||
gtin: $data['productFullInformation']['eanCode'] ?? null,
|
||||
notes: $data['productFullInformation']['description'] ?? null,
|
||||
datasheets: $this->productMediaToDatasheets($data['productMedia'] ?? []),
|
||||
parameters: $this->technicalAttributesToParameters($data['productFullInformation']['technicalAttributes'] ?? []),
|
||||
vendor_infos: [$this->queryPrices($data['shortProductNumber'])]
|
||||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::GTIN,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
$domains = [];
|
||||
foreach (ConradShopIDs::cases() as $shopID) {
|
||||
$domains[] = $shopID->getDomain();
|
||||
}
|
||||
return array_unique($domains);
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Input: https://www.conrad.de/de/p/apple-iphone-air-wolkenweiss-256-gb-eek-a-a-g-16-5-cm-6-5-zoll-3475299.html
|
||||
//The numbers before the optional .html are the product ID
|
||||
|
||||
$matches = [];
|
||||
if (preg_match('/-(\d+)(\.html)?$/', $url, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,14 @@ class DigikeyProvider implements InfoProviderInterface
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() === 404) {
|
||||
//No media found
|
||||
return [
|
||||
'datasheets' => [],
|
||||
'images' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$media_array = $response->toArray();
|
||||
|
||||
foreach ($media_array['MediaLinks'] as $media_link) {
|
||||
|
||||
@@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\Element14Settings;
|
||||
use Composer\CaBundle\CaBundle;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class Element14Provider implements InfoProviderInterface
|
||||
class Element14Provider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://api.element14.com/catalog/products';
|
||||
@@ -309,4 +309,21 @@ class Element14Provider implements InfoProviderInterface
|
||||
ProviderCapabilities::DATASHEET,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['element14.com', 'farnell.com', 'newark.com'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Input URL example: https://de.farnell.com/on-semiconductor/bc547b/transistor-npn-to-92/dp/1017673
|
||||
//The digits after the /dp/ are the part ID
|
||||
$matches = [];
|
||||
if (preg_match('#/dp/(\d+)#', $url, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
437
src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
Normal file
437
src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
Normal file
@@ -0,0 +1,437 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Exceptions\ProviderIDNotSupportedException;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Settings\InfoProviderSystem\GenericWebProviderSettings;
|
||||
use Brick\Schema\Interfaces\BreadcrumbList;
|
||||
use Brick\Schema\Interfaces\ImageObject;
|
||||
use Brick\Schema\Interfaces\Product;
|
||||
use Brick\Schema\Interfaces\PropertyValue;
|
||||
use Brick\Schema\Interfaces\QuantitativeValue;
|
||||
use Brick\Schema\Interfaces\Thing;
|
||||
use Brick\Schema\SchemaReader;
|
||||
use Brick\Schema\SchemaTypeList;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class GenericWebProvider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Website';
|
||||
|
||||
private readonly HttpClientInterface $httpClient;
|
||||
|
||||
public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings,
|
||||
private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever,
|
||||
)
|
||||
{
|
||||
$this->httpClient = $httpClient->withOptions(
|
||||
[
|
||||
'headers' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
|
||||
],
|
||||
'timeout' => 15,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Generic Web URL',
|
||||
'description' => 'Tries to extract a part from a given product webpage URL using common metadata standards like JSON-LD and OpenGraph.',
|
||||
//'url' => 'https://example.com',
|
||||
'disabled_help' => 'Enable in settings to use this provider',
|
||||
'settings_class' => GenericWebProviderSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'generic_web';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->settings->enabled;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$url = $this->fixAndValidateURL($keyword);
|
||||
|
||||
//Before loading the page, try to delegate to another provider
|
||||
$delegatedPart = $this->delegateToOtherProvider($url);
|
||||
if ($delegatedPart !== null) {
|
||||
return [$delegatedPart];
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
$this->getDetails($keyword, false) //We already tried delegation
|
||||
]; } catch (ProviderIDNotSupportedException $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function extractShopName(string $url): string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if ($host === false || $host === null) {
|
||||
return self::DISTRIBUTOR_NAME;
|
||||
}
|
||||
return $host;
|
||||
}
|
||||
|
||||
private function breadcrumbToCategory(?BreadcrumbList $breadcrumbList): ?string
|
||||
{
|
||||
if ($breadcrumbList === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$items = $breadcrumbList->itemListElement->getValues();
|
||||
if (count($items) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
//Build our category from the breadcrumb items
|
||||
$categories = [];
|
||||
foreach ($items as $item) {
|
||||
if (isset($item->name)) {
|
||||
$categories[] = trim($item->name->toString());
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(' -> ', $categories);
|
||||
}
|
||||
|
||||
private function productToPart(Product $product, string $url, Crawler $dom, ?BreadcrumbList $categoryBreadcrumb): PartDetailDTO
|
||||
{
|
||||
$notes = $product->description->toString() ?? "";
|
||||
if ($product->disambiguatingDescription !== null) {
|
||||
if (!empty($notes)) {
|
||||
$notes .= "\n\n";
|
||||
}
|
||||
$notes .= $product->disambiguatingDescription->toString();
|
||||
}
|
||||
|
||||
|
||||
//Extract vendor infos
|
||||
$vendor_infos = null;
|
||||
$offer = $product->offers->getFirstValue();
|
||||
if ($offer !== null) {
|
||||
$prices = [];
|
||||
if ($offer->price->toString() !== null) {
|
||||
$prices = [new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $offer->price->toString(),
|
||||
currency_iso_code: $offer->priceCurrency?->toString()
|
||||
)];
|
||||
} else { //Check for nested offers (like IKEA does it)
|
||||
$offer2 = $offer->offers->getFirstValue();
|
||||
if ($offer2 !== null && $offer2->price->toString() !== null) {
|
||||
$prices = [
|
||||
new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $offer2->price->toString(),
|
||||
currency_iso_code: $offer2->priceCurrency?->toString()
|
||||
)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$vendor_infos = [new PurchaseInfoDTO(
|
||||
distributor_name: $this->extractShopName($url),
|
||||
order_number: $product->sku?->toString() ?? $product->identifier?->toString() ?? 'Unknown',
|
||||
prices: $prices,
|
||||
product_url: $offer->url?->toString() ?? $url,
|
||||
)];
|
||||
}
|
||||
|
||||
//Extract image:
|
||||
$image = null;
|
||||
if ($product->image !== null) {
|
||||
$imageObj = $product->image->getFirstValue();
|
||||
if (is_string($imageObj)) {
|
||||
$image = $imageObj;
|
||||
} else if ($imageObj instanceof ImageObject) {
|
||||
$image = $imageObj->contentUrl?->toString() ?? $imageObj->url?->toString();
|
||||
}
|
||||
}
|
||||
|
||||
//Extract parameters from additionalProperty
|
||||
$parameters = [];
|
||||
foreach ($product->additionalProperty->getValues() as $property) {
|
||||
if ($property instanceof PropertyValue) { //TODO: Handle minValue and maxValue
|
||||
if ($property->unitText->toString() !== null) {
|
||||
$parameters[] = ParameterDTO::parseValueField(
|
||||
name: $property->name->toString() ?? 'Unknown',
|
||||
value: $property->value->toString() ?? '',
|
||||
unit: $property->unitText->toString()
|
||||
);
|
||||
} else {
|
||||
$parameters[] = ParameterDTO::parseValueIncludingUnit(
|
||||
name: $property->name->toString() ?? 'Unknown',
|
||||
value: $property->value->toString() ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Try to extract weight
|
||||
$mass = null;
|
||||
if (($weight = $product->weight?->getFirstValue()) instanceof QuantitativeValue) {
|
||||
$mass = $weight->value->toString();
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $url,
|
||||
name: $product->name?->toString() ?? $product->alternateName?->toString() ?? $product->mpn?->toString() ?? 'Unknown Name',
|
||||
description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '',
|
||||
category: $this->breadcrumbToCategory($categoryBreadcrumb) ?? $product->category?->toString(),
|
||||
manufacturer: self::propertyOrString($product->manufacturer) ?? self::propertyOrString($product->brand),
|
||||
mpn: $product->mpn?->toString(),
|
||||
preview_image_url: $image,
|
||||
provider_url: $url,
|
||||
gtin: $product->gtin14?->toString() ?? $product->gtin13?->toString() ?? $product->gtin12?->toString() ?? $product->gtin8?->toString(),
|
||||
notes: $notes,
|
||||
parameters: $parameters,
|
||||
vendor_infos: $vendor_infos,
|
||||
mass: $mass,
|
||||
);
|
||||
}
|
||||
|
||||
private static function propertyOrString(SchemaTypeList|Thing|string|null $value, string $property = "name"): ?string
|
||||
{
|
||||
if ($value instanceof SchemaTypeList) {
|
||||
$value = $value->getFirstValue();
|
||||
}
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $value->$property?->toString();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the content of a meta tag by its name or property attribute, or null if not found
|
||||
* @param Crawler $dom
|
||||
* @param string $name
|
||||
* @return string|null
|
||||
*/
|
||||
private function getMetaContent(Crawler $dom, string $name): ?string
|
||||
{
|
||||
$meta = $dom->filter('meta[property="'.$name.'"]');
|
||||
if ($meta->count() > 0) {
|
||||
return $meta->attr('content');
|
||||
}
|
||||
|
||||
//Try name attribute
|
||||
$meta = $dom->filter('meta[name="'.$name.'"]');
|
||||
if ($meta->count() > 0) {
|
||||
return $meta->attr('content');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates the URL to another provider if possible, otherwise return null
|
||||
* @param string $url
|
||||
* @return SearchResultDTO|null
|
||||
*/
|
||||
private function delegateToOtherProvider(string $url): ?SearchResultDTO
|
||||
{
|
||||
//Extract domain from url:
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if ($host === false || $host === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$provider = $this->providerRegistry->getProviderHandlingDomain($host);
|
||||
|
||||
if ($provider !== null && $provider->isActive() && $provider->getProviderKey() !== $this->getProviderKey()) {
|
||||
try {
|
||||
$id = $provider->getIDFromURL($url);
|
||||
if ($id !== null) {
|
||||
$results = $this->infoRetriever->searchByKeyword($id, [$provider]);
|
||||
if (count($results) > 0) {
|
||||
return $results[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (ProviderIDNotSupportedException $e) {
|
||||
//Ignore and continue
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function fixAndValidateURL(string $url): string
|
||||
{
|
||||
$originalUrl = $url;
|
||||
|
||||
//Add scheme if missing
|
||||
if (!preg_match('/^https?:\/\//', $url)) {
|
||||
//Remove any leading slashes
|
||||
$url = ltrim($url, '/');
|
||||
|
||||
$url = 'https://'.$url;
|
||||
}
|
||||
|
||||
//If this is not a valid URL with host, domain and path, throw an exception
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) === false ||
|
||||
parse_url($url, PHP_URL_HOST) === null ||
|
||||
parse_url($url, PHP_URL_PATH) === null) {
|
||||
throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$originalUrl);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
public function getDetails(string $id, bool $check_for_delegation = true): PartDetailDTO
|
||||
{
|
||||
$url = $this->fixAndValidateURL($id);
|
||||
|
||||
if ($check_for_delegation) {
|
||||
//Before loading the page, try to delegate to another provider
|
||||
$delegatedPart = $this->delegateToOtherProvider($url);
|
||||
if ($delegatedPart !== null) {
|
||||
return $this->infoRetriever->getDetailsForSearchResult($delegatedPart);
|
||||
}
|
||||
}
|
||||
|
||||
//Try to get the webpage content
|
||||
$response = $this->httpClient->request('GET', $url);
|
||||
$content = $response->getContent();
|
||||
|
||||
$dom = new Crawler($content);
|
||||
|
||||
//Try to determine a canonical URL
|
||||
$canonicalURL = $url;
|
||||
if ($dom->filter('link[rel="canonical"]')->count() > 0) {
|
||||
$canonicalURL = $dom->filter('link[rel="canonical"]')->attr('href');
|
||||
} else if ($dom->filter('meta[property="og:url"]')->count() > 0) {
|
||||
$canonicalURL = $dom->filter('meta[property="og:url"]')->attr('content');
|
||||
}
|
||||
|
||||
//If the canonical URL is relative, make it absolute
|
||||
if (parse_url($canonicalURL, PHP_URL_SCHEME) === null) {
|
||||
$parsedUrl = parse_url($url);
|
||||
$scheme = $parsedUrl['scheme'] ?? 'https';
|
||||
$host = $parsedUrl['host'] ?? '';
|
||||
$canonicalURL = $scheme.'://'.$host.$canonicalURL;
|
||||
}
|
||||
|
||||
|
||||
$schemaReader = SchemaReader::forAllFormats();
|
||||
$things = $schemaReader->readHtml($content, $canonicalURL);
|
||||
|
||||
//Try to find a breadcrumb schema to extract the category
|
||||
$categoryBreadCrumbs = null;
|
||||
foreach ($things as $thing) {
|
||||
if ($thing instanceof BreadcrumbList) {
|
||||
$categoryBreadCrumbs = $thing;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
//Try to find a Product schema
|
||||
foreach ($things as $thing) {
|
||||
if ($thing instanceof Product) {
|
||||
return $this->productToPart($thing, $canonicalURL, $dom, $categoryBreadCrumbs);
|
||||
}
|
||||
}
|
||||
|
||||
//If no JSON-LD data is found, try to extract basic data from meta tags
|
||||
$pageTitle = $dom->filter('title')->count() > 0 ? $dom->filter('title')->text() : 'Unknown';
|
||||
|
||||
$prices = [];
|
||||
if ($price = $this->getMetaContent($dom, 'product:price:amount')) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $price,
|
||||
currency_iso_code: $this->getMetaContent($dom, 'product:price:currency'),
|
||||
);
|
||||
} else {
|
||||
//Amazon fallback
|
||||
$amazonAmount = $dom->filter('input[type="hidden"][name*="amount"]');
|
||||
if ($amazonAmount->count() > 0) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $amazonAmount->first()->attr('value'),
|
||||
currency_iso_code: $dom->filter('input[type="hidden"][name*="currencyCode"]')->first()->attr('value'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$vendor_infos = [new PurchaseInfoDTO(
|
||||
distributor_name: $this->extractShopName($canonicalURL),
|
||||
order_number: 'Unknown',
|
||||
prices: $prices,
|
||||
product_url: $canonicalURL,
|
||||
)];
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $canonicalURL,
|
||||
name: $this->getMetaContent($dom, 'og:title') ?? $pageTitle,
|
||||
description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '',
|
||||
manufacturer: $this->getMetaContent($dom, 'product:brand'),
|
||||
preview_image_url: $this->getMetaContent($dom, 'og:image'),
|
||||
provider_url: $canonicalURL,
|
||||
vendor_infos: $vendor_infos,
|
||||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::GTIN,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\LCSCSettings;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class LCSCProvider implements BatchInfoProviderInterface
|
||||
class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm';
|
||||
@@ -452,4 +452,21 @@ class LCSCProvider implements BatchInfoProviderInterface
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['lcsc.com'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Input example: https://www.lcsc.com/product-detail/C258144.html?s_z=n_BC547
|
||||
//The part between the "C" and the ".html" is the unique ID
|
||||
|
||||
$matches = [];
|
||||
if (preg_match("#/product-detail/(\w+)\.html#", $url, $matches) > 0) {
|
||||
return $matches[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,13 +397,13 @@ class OEMSecretsProvider implements InfoProviderInterface
|
||||
* Generates a cache key for storing part details based on the provided provider ID.
|
||||
*
|
||||
* This method creates a unique cache key by prefixing the provider ID with 'part_details_'
|
||||
* and hashing the provider ID using MD5 to ensure a consistent and compact key format.
|
||||
* and hashing the provider ID using XXH3 to ensure a consistent and compact key format.
|
||||
*
|
||||
* @param string $provider_id The unique identifier of the provider or part.
|
||||
* @return string The generated cache key.
|
||||
*/
|
||||
private function getCacheKey(string $provider_id): string {
|
||||
return 'oemsecrets_part_' . md5($provider_id);
|
||||
return 'oemsecrets_part_' . hash('xxh3', $provider_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -680,7 +680,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
||||
if (is_array($prices)) {
|
||||
// Step 1: Check if prices exist in the preferred currency
|
||||
if (isset($prices[$this->settings->currency]) && is_array($prices[$this->settings->currency])) {
|
||||
$priceDetails = $prices[$this->$this->settings->currency];
|
||||
$priceDetails = $prices[$this->settings->currency];
|
||||
foreach ($priceDetails as $priceDetail) {
|
||||
if (
|
||||
is_array($priceDetail) &&
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user