mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-19 18:01:30 +00:00
Merge branch 'master' into l10n_master
This commit is contained in:
commit
fce08d574d
88 changed files with 2730 additions and 1537 deletions
5
.env
5
.env
|
|
@ -71,6 +71,11 @@ DISABLE_WEB_UPDATES=1
|
||||||
# Restoring backups is a destructive operation that could overwrite your database.
|
# Restoring backups is a destructive operation that could overwrite your database.
|
||||||
DISABLE_BACKUP_RESTORE=1
|
DISABLE_BACKUP_RESTORE=1
|
||||||
|
|
||||||
|
# Disable backup download from the Update Manager UI (0=enabled, 1=disabled).
|
||||||
|
# Backups contain sensitive data including password hashes and secrets.
|
||||||
|
# When enabled, users must confirm their password before downloading.
|
||||||
|
DISABLE_BACKUP_DOWNLOAD=1
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# SAML Single sign on-settings
|
# SAML Single sign on-settings
|
||||||
###################################################################################
|
###################################################################################
|
||||||
|
|
|
||||||
16
.github/workflows/assets_artifact_build.yml
vendored
16
.github/workflows/assets_artifact_build.yml
vendored
|
|
@ -8,6 +8,9 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- '*'
|
- '*'
|
||||||
- "!l10n_*" # Dont test localization branches
|
- "!l10n_*" # Dont test localization branches
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
- 'v*.*.*-**'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- '*'
|
- '*'
|
||||||
|
|
@ -17,6 +20,8 @@ jobs:
|
||||||
assets_artifact_build:
|
assets_artifact_build:
|
||||||
name: Build assets artifact
|
name: Build assets artifact
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
APP_ENV: prod
|
APP_ENV: prod
|
||||||
|
|
@ -80,13 +85,20 @@ jobs:
|
||||||
run: zip -r /tmp/partdb_assets.zip public/build/ vendor/
|
run: zip -r /tmp/partdb_assets.zip public/build/ vendor/
|
||||||
|
|
||||||
- name: Upload assets artifact
|
- name: Upload assets artifact
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: Only dependencies and built assets
|
name: Only dependencies and built assets
|
||||||
path: /tmp/partdb_assets.zip
|
path: /tmp/partdb_assets.zip
|
||||||
|
|
||||||
- name: Upload full artifact
|
- name: Upload full artifact
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: Full Part-DB including dependencies and built assets
|
name: Full Part-DB including dependencies and built assets
|
||||||
path: /tmp/partdb_with_assets.zip
|
path: /tmp/partdb_with_assets.zip
|
||||||
|
|
||||||
|
- name: Upload assets as release attachment
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: |
|
||||||
|
gh release upload "${{ github.ref_name }}" /tmp/partdb_assets.zip /tmp/partdb_with_assets.zip --clobber
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
|
||||||
4
.github/workflows/docker_build.yml
vendored
4
.github/workflows/docker_build.yml
vendored
|
|
@ -98,7 +98,7 @@ jobs:
|
||||||
-
|
-
|
||||||
name: Upload digest
|
name: Upload digest
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.platform-slug }}
|
name: digests-${{ matrix.platform-slug }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
|
|
@ -113,7 +113,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Download digests
|
name: Download digests
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|
|
||||||
4
.github/workflows/docker_frankenphp.yml
vendored
4
.github/workflows/docker_frankenphp.yml
vendored
|
|
@ -99,7 +99,7 @@ jobs:
|
||||||
-
|
-
|
||||||
name: Upload digest
|
name: Upload digest
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.platform-slug }}
|
name: digests-${{ matrix.platform-slug }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
|
|
@ -114,7 +114,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Download digests
|
name: Download digests
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
2.8.0
|
2.8.1
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export default class extends Controller {
|
||||||
maxItems: 1,
|
maxItems: 1,
|
||||||
createOnBlur: true,
|
createOnBlur: true,
|
||||||
selectOnTab: true,
|
selectOnTab: true,
|
||||||
|
clearAfterSelect: true,
|
||||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
||||||
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||||
dropdownParent: dropdownParent,
|
dropdownParent: dropdownParent,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ export default class extends Controller {
|
||||||
valueField: "id",
|
valueField: "id",
|
||||||
labelField: "name",
|
labelField: "name",
|
||||||
dropdownParent: dropdownParent,
|
dropdownParent: dropdownParent,
|
||||||
|
selectOnTab: true,
|
||||||
|
clearAfterSelect: true,
|
||||||
preload: "focus",
|
preload: "focus",
|
||||||
render: {
|
render: {
|
||||||
item: (data, escape) => {
|
item: (data, escape) => {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export default class extends Controller {
|
||||||
selectOnTab: true,
|
selectOnTab: true,
|
||||||
maxOptions: null,
|
maxOptions: null,
|
||||||
dropdownParent: dropdownParent,
|
dropdownParent: dropdownParent,
|
||||||
|
clearAfterSelect: true,
|
||||||
|
|
||||||
render: {
|
render: {
|
||||||
item: this.renderItem.bind(this),
|
item: this.renderItem.bind(this),
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ export default class extends Controller {
|
||||||
maxItems: 1000,
|
maxItems: 1000,
|
||||||
allowEmptyOption: true,
|
allowEmptyOption: true,
|
||||||
dropdownParent: dropdownParent,
|
dropdownParent: dropdownParent,
|
||||||
|
selectOnTab: true,
|
||||||
|
clearAfterSelect: true,
|
||||||
plugins: ['remove_button'],
|
plugins: ['remove_button'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
|
|
||||||
import {Controller} from "@hotwired/stimulus";
|
import {Controller} from "@hotwired/stimulus";
|
||||||
import {default as TreeController} from "./tree_controller";
|
import {default as TreeController} from "./tree_controller";
|
||||||
|
import {EVENT_INITIALIZED} from "@jbtronics/bs-treeview";
|
||||||
|
|
||||||
export default class extends TreeController {
|
export default class extends TreeController {
|
||||||
static targets = [ "tree", 'sourceText' ];
|
static targets = [ "tree", 'sourceText' ];
|
||||||
|
|
@ -40,6 +41,8 @@ export default class extends TreeController {
|
||||||
//Check if we have a saved mode
|
//Check if we have a saved mode
|
||||||
const stored_mode = localStorage.getItem(this._storage_key);
|
const stored_mode = localStorage.getItem(this._storage_key);
|
||||||
|
|
||||||
|
this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined
|
||||||
|
|
||||||
//Use stored mode if possible, otherwise use default
|
//Use stored mode if possible, otherwise use default
|
||||||
if(stored_mode) {
|
if(stored_mode) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -55,6 +58,39 @@ export default class extends TreeController {
|
||||||
|
|
||||||
//Register an event listener which checks if the tree needs to be updated
|
//Register an event listener which checks if the tree needs to be updated
|
||||||
document.addEventListener('turbo:render', this.doUpdateIfNeeded.bind(this));
|
document.addEventListener('turbo:render', this.doUpdateIfNeeded.bind(this));
|
||||||
|
|
||||||
|
//Register an event listener, to check if we end up on a page we can highlight in the tree, if so then higlight it
|
||||||
|
document.addEventListener('turbo:load', this._onTurboLoad.bind(this));
|
||||||
|
//On initial page load the tree is not available yet, so do another check after the tree is initialized
|
||||||
|
this.treeTarget.addEventListener(EVENT_INITIALIZED, (event) => {
|
||||||
|
this.selectNodeWithURL(document.location)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_onTurboLoad(event) {
|
||||||
|
this.selectNodeWithURL(event.detail.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectNodeWithURL(url) {
|
||||||
|
//Get path from url
|
||||||
|
const path = new URL(url).pathname;
|
||||||
|
|
||||||
|
if (!this._tree) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Unselect all nodes
|
||||||
|
this._tree.unselectAll({silent: true, ignorePreventUnselect: true});
|
||||||
|
|
||||||
|
//Try to find a node with this path as data-path
|
||||||
|
const nodes = this._tree.findNodes(path, "href");
|
||||||
|
if (nodes.length !== 1) {
|
||||||
|
return; //We can only work with exactly one node, if there are multiple nodes with the same path, we cannot know which one to select, so we do nothing
|
||||||
|
}
|
||||||
|
const node = nodes[0];
|
||||||
|
|
||||||
|
node.setSelected(true, {ignorePreventUnselect: true, silent: true});
|
||||||
|
this._tree.revealNode(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
doUpdateIfNeeded()
|
doUpdateIfNeeded()
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export default class extends Controller {
|
||||||
searchField: 'text',
|
searchField: 'text',
|
||||||
orderField: 'text',
|
orderField: 'text',
|
||||||
dropdownParent: dropdownParent,
|
dropdownParent: dropdownParent,
|
||||||
|
clearAfterSelect: true,
|
||||||
|
|
||||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
||||||
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export default class extends Controller {
|
||||||
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
|
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
|
||||||
splitOn: null,
|
splitOn: null,
|
||||||
dropdownParent: dropdownParent,
|
dropdownParent: dropdownParent,
|
||||||
|
clearAfterSelect: true,
|
||||||
|
|
||||||
searchField: [
|
searchField: [
|
||||||
{field: "text", weight : 2},
|
{field: "text", weight : 2},
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export default class extends Controller {
|
||||||
createOnBlur: true,
|
createOnBlur: true,
|
||||||
create: true,
|
create: true,
|
||||||
dropdownParent: dropdownParent,
|
dropdownParent: dropdownParent,
|
||||||
|
clearAfterSelect: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
if(this.element.dataset.autocomplete) {
|
if(this.element.dataset.autocomplete) {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ export default class extends Controller {
|
||||||
*/
|
*/
|
||||||
_tree = null;
|
_tree = null;
|
||||||
|
|
||||||
|
_frame = "frame";
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
const treeElement = this.treeTarget;
|
const treeElement = this.treeTarget;
|
||||||
if (!treeElement) {
|
if (!treeElement) {
|
||||||
|
|
@ -48,6 +50,7 @@ export default class extends Controller {
|
||||||
|
|
||||||
this._url = this.element.dataset.treeUrl;
|
this._url = this.element.dataset.treeUrl;
|
||||||
this._data = this.element.dataset.treeData;
|
this._data = this.element.dataset.treeData;
|
||||||
|
this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined
|
||||||
|
|
||||||
if(this.element.dataset.treeShowTags === "true") {
|
if(this.element.dataset.treeShowTags === "true") {
|
||||||
this._showTags = true;
|
this._showTags = true;
|
||||||
|
|
@ -99,8 +102,7 @@ export default class extends Controller {
|
||||||
onNodeSelected: (event) => {
|
onNodeSelected: (event) => {
|
||||||
const node = event.detail.node;
|
const node = event.detail.node;
|
||||||
if (node.href) {
|
if (node.href) {
|
||||||
window.Turbo.visit(node.href, {action: "advance"});
|
window.Turbo.visit(node.href, {action: "advance", frame: this._frame});
|
||||||
this._registerURLWatcher(node);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, [BS5Theme, BS53Theme, FAIconTheme]);
|
}, [BS5Theme, BS53Theme, FAIconTheme]);
|
||||||
|
|
@ -110,41 +112,12 @@ export default class extends Controller {
|
||||||
const treeView = event.detail.treeView;
|
const treeView = event.detail.treeView;
|
||||||
treeView.revealNode(treeView.getSelected());
|
treeView.revealNode(treeView.getSelected());
|
||||||
|
|
||||||
//Add the url watcher to all selected nodes
|
|
||||||
for (const node of treeView.getSelected()) {
|
|
||||||
this._registerURLWatcher(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Add contextmenu event listener to the tree, which allows us to open the links in a new tab with a right click
|
//Add contextmenu event listener to the tree, which allows us to open the links in a new tab with a right click
|
||||||
treeView.getTreeElement().addEventListener("contextmenu", this._onContextMenu.bind(this));
|
treeView.getTreeElement().addEventListener("contextmenu", this._onContextMenu.bind(this));
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_registerURLWatcher(node)
|
|
||||||
{
|
|
||||||
//Register a watcher for a location change, which will unselect the node, if the location changes
|
|
||||||
const desired_url = node.href;
|
|
||||||
|
|
||||||
//Ensure that the node is unselected, if the location changes
|
|
||||||
const unselectNode = () => {
|
|
||||||
//Parse url so we can properly compare them
|
|
||||||
const desired = new URL(node.href, window.location.origin);
|
|
||||||
|
|
||||||
//We only compare the pathname, because the hash and parameters should not matter
|
|
||||||
if(window.location.pathname !== desired.pathname) {
|
|
||||||
//The ignore parameter is important here, otherwise the node will not be unselected
|
|
||||||
node.setSelected(false, {silent: true, ignorePreventUnselect: true});
|
|
||||||
|
|
||||||
//Unregister the watcher
|
|
||||||
document.removeEventListener('turbo:load', unselectNode);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//Register the watcher via hotwire turbo
|
|
||||||
//We must just load to have the new url in window.location
|
|
||||||
document.addEventListener('turbo:load', unselectNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onContextMenu(event)
|
_onContextMenu(event)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ export default class extends Controller
|
||||||
searchField: "name",
|
searchField: "name",
|
||||||
//labelField: "name",
|
//labelField: "name",
|
||||||
valueField: "name",
|
valueField: "name",
|
||||||
|
clearAfterSelect: true,
|
||||||
onItemAdd: this.onItemAdd.bind(this),
|
onItemAdd: this.onItemAdd.bind(this),
|
||||||
render: {
|
render: {
|
||||||
option: (data, escape) => {
|
option: (data, escape) => {
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,11 @@
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.4.*",
|
"require": "7.4.*",
|
||||||
"docker": true
|
"docker": true
|
||||||
|
},
|
||||||
|
"phpstan/extension-installer": {
|
||||||
|
"ignore" : [
|
||||||
|
"ekino/phpstan-banned-code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
418
composer.lock
generated
418
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -25,5 +25,5 @@ framework:
|
||||||
adapter: cache.app
|
adapter: cache.app
|
||||||
|
|
||||||
cache.settings:
|
cache.settings:
|
||||||
adapter: cache.app
|
adapter: cache.system
|
||||||
tags: true
|
tags: true
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ twig:
|
||||||
saml_enabled: '%partdb.saml.enabled%'
|
saml_enabled: '%partdb.saml.enabled%'
|
||||||
part_preview_generator: '@App\Services\Attachments\PartPreviewGenerator'
|
part_preview_generator: '@App\Services\Attachments\PartPreviewGenerator'
|
||||||
|
|
||||||
|
# Bootstrap grid classes used for horizontal form layouts
|
||||||
|
col_label: 'col-sm-3 col-lg-2' # The column classes for form labels
|
||||||
|
col_input: 'col-sm-9 col-lg-10' # The column classes for form input fields
|
||||||
|
offset_label: 'offset-sm-3 offset-lg-2' # Offset classes for elements that should be aligned with the input fields (e.g., submit buttons)
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
twig:
|
twig:
|
||||||
strict_variables: true
|
strict_variables: true
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,18 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
|
||||||
* `ALLOW_EMAIL_PW_RESET`: Set this value to true, if you want to allow users to reset their password via an email
|
* `ALLOW_EMAIL_PW_RESET`: Set this value to true, if you want to allow users to reset their password via an email
|
||||||
notification. You have to configure the mail provider first before via the MAILER_DSN setting.
|
notification. You have to configure the mail provider first before via the MAILER_DSN setting.
|
||||||
|
|
||||||
|
### Update manager settings
|
||||||
|
* `DISABLE_WEB_UPDATES` (default `1`): Set this to 0 to enable web-based updates. When enabled, you can perform updates
|
||||||
|
via the web interface in the update manager. This is disabled by default for security reasons, as it can be a risk if
|
||||||
|
not used carefully. You can still use the CLI commands to perform updates, even when web updates are disabled.
|
||||||
|
* `DISABLE_BACKUP_RESTORE` (default `1`): Set this to 0 to enable backup restore via the web interface. When enabled, you can
|
||||||
|
restore backups via the web interface in the update manager. This is disabled by default for security reasons, as it can
|
||||||
|
be a risk if not used carefully. You can still use the CLI commands to perform backup restores, even when web-based
|
||||||
|
backup restore is disabled.
|
||||||
|
* `DISABLE_BACKUP_DOWNLOAD` (default `1`): Set this to 0 to enable backup download via the web interface. When enabled, you can download backups via the web interface
|
||||||
|
in the update manager. This is disabled by default for security reasons, as it can be a risk if not used carefully, as
|
||||||
|
the downloads contain sensitive data like password hashes or secrets.
|
||||||
|
|
||||||
### Table related settings
|
### Table related settings
|
||||||
|
|
||||||
* `TABLE_DEFAULT_PAGE_SIZE`: The default page size for tables. This is the number of rows which are shown per page. Set
|
* `TABLE_DEFAULT_PAGE_SIZE`: The default page size for tables. This is the number of rows which are shown per page. Set
|
||||||
|
|
|
||||||
73
migrations/Version20260307204859.php
Normal file
73
migrations/Version20260307204859.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use App\Migration\AbstractMultiPlatformMigration;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260307204859 extends AbstractMultiPlatformMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Increase the length of the vendor_barcode field in part_lots to 1000 characters and update the index accordingly';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mySQLUp(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode(100))');
|
||||||
|
$this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode LONGTEXT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mySQLDown(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode)');
|
||||||
|
$this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sqLiteUp(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM part_lots');
|
||||||
|
$this->addSql('DROP TABLE part_lots');
|
||||||
|
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, vendor_barcode CLOB DEFAULT NULL, last_stocktake_at DATETIME DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES storelocations (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||||
|
$this->addSql('INSERT INTO part_lots (id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at) SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM __temp__part_lots');
|
||||||
|
$this->addSql('DROP TABLE __temp__part_lots');
|
||||||
|
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
|
||||||
|
$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 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_barcode ON part_lots (vendor_barcode)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sqLiteDown(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, 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_stocktake_at DATETIME 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_stocktake_at, 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_stocktake_at, 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)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postgreSQLUp(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX part_lots_idx_barcode');
|
||||||
|
$this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE TEXT');
|
||||||
|
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postgreSQLDown(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX part_lots_idx_barcode');
|
||||||
|
$this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE VARCHAR(255)');
|
||||||
|
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||||
|
}
|
||||||
|
}
|
||||||
86
phpstan.banned_code.neon
Normal file
86
phpstan.banned_code.neon
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Manually configure ekino/phpstan-banned-code to detect usage of echo, eval, die/exit, print, shell execution and a set of functions that should not be used in production code.
|
||||||
|
|
||||||
|
parametersSchema:
|
||||||
|
banned_code: structure([
|
||||||
|
nodes: listOf(structure([
|
||||||
|
type: string()
|
||||||
|
functions: schema(listOf(string()), nullable())
|
||||||
|
]))
|
||||||
|
use_from_tests: bool()
|
||||||
|
non_ignorable: bool()
|
||||||
|
])
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
banned_code:
|
||||||
|
nodes:
|
||||||
|
# enable detection of echo
|
||||||
|
-
|
||||||
|
type: Stmt_Echo
|
||||||
|
functions: null
|
||||||
|
|
||||||
|
# enable detection of eval
|
||||||
|
-
|
||||||
|
type: Expr_Eval
|
||||||
|
functions: null
|
||||||
|
|
||||||
|
# enable detection of die/exit
|
||||||
|
-
|
||||||
|
type: Expr_Exit
|
||||||
|
functions: null
|
||||||
|
|
||||||
|
# enable detection of a set of functions
|
||||||
|
-
|
||||||
|
type: Expr_FuncCall
|
||||||
|
functions:
|
||||||
|
- dd
|
||||||
|
- debug_backtrace
|
||||||
|
- dump
|
||||||
|
- exec
|
||||||
|
- passthru
|
||||||
|
- phpinfo
|
||||||
|
- print_r
|
||||||
|
- proc_open
|
||||||
|
- shell_exec
|
||||||
|
- system
|
||||||
|
- var_dump
|
||||||
|
|
||||||
|
# enable detection of print statements
|
||||||
|
-
|
||||||
|
type: Expr_Print
|
||||||
|
functions: null
|
||||||
|
|
||||||
|
# enable detection of shell execution by backticks
|
||||||
|
-
|
||||||
|
type: Expr_ShellExec
|
||||||
|
functions: null
|
||||||
|
|
||||||
|
# enable detection of empty()
|
||||||
|
#-
|
||||||
|
# type: Expr_Empty
|
||||||
|
# functions: null
|
||||||
|
|
||||||
|
# enable detection of `use Tests\Foo\Bar` in a non-test file
|
||||||
|
use_from_tests: true
|
||||||
|
|
||||||
|
# when true, errors cannot be excluded
|
||||||
|
non_ignorable: false
|
||||||
|
|
||||||
|
services:
|
||||||
|
-
|
||||||
|
class: Ekino\PHPStanBannedCode\Rules\BannedNodesRule
|
||||||
|
tags:
|
||||||
|
- phpstan.rules.rule
|
||||||
|
arguments:
|
||||||
|
- '%banned_code.nodes%'
|
||||||
|
|
||||||
|
-
|
||||||
|
class: Ekino\PHPStanBannedCode\Rules\BannedUseTestRule
|
||||||
|
tags:
|
||||||
|
- phpstan.rules.rule
|
||||||
|
arguments:
|
||||||
|
- '%banned_code.use_from_tests%'
|
||||||
|
|
||||||
|
-
|
||||||
|
class: Ekino\PHPStanBannedCode\Rules\BannedNodesErrorBuilder
|
||||||
|
arguments:
|
||||||
|
- '%banned_code.non_ignorable%'
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
includes:
|
||||||
|
- phpstan.banned_code.neon
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
|
|
||||||
level: 5
|
level: 5
|
||||||
|
|
@ -6,9 +9,6 @@ parameters:
|
||||||
- src
|
- src
|
||||||
# - tests
|
# - tests
|
||||||
|
|
||||||
banned_code:
|
|
||||||
non_ignorable: false # Allow to ignore some banned code
|
|
||||||
|
|
||||||
excludePaths:
|
excludePaths:
|
||||||
- src/DataTables/Adapter/*
|
- src/DataTables/Adapter/*
|
||||||
- src/Configuration/*
|
- src/Configuration/*
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated on Sun Mar 1 11:46:09 UTC 2026
|
# Generated on Tue Mar 3 14:26:21 UTC 2026
|
||||||
# This file contains all footprints available in the offical KiCAD library
|
# This file contains all footprints available in the offical KiCAD library
|
||||||
Audio_Module:Reverb_BTDR-1H
|
Audio_Module:Reverb_BTDR-1H
|
||||||
Audio_Module:Reverb_BTDR-1V
|
Audio_Module:Reverb_BTDR-1V
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated on Sun Mar 1 11:46:51 UTC 2026
|
# Generated on Tue Mar 3 14:27:05 UTC 2026
|
||||||
# This file contains all symbols available in the offical KiCAD library
|
# This file contains all symbols available in the offical KiCAD library
|
||||||
4xxx:14528
|
4xxx:14528
|
||||||
4xxx:14529
|
4xxx:14529
|
||||||
|
|
@ -20842,6 +20842,9 @@ Sensor_Pressure:40PC015G
|
||||||
Sensor_Pressure:40PC100G
|
Sensor_Pressure:40PC100G
|
||||||
Sensor_Pressure:40PC150G
|
Sensor_Pressure:40PC150G
|
||||||
Sensor_Pressure:40PC250G
|
Sensor_Pressure:40PC250G
|
||||||
|
Sensor_Pressure:ABPxxxxxxxxx0
|
||||||
|
Sensor_Pressure:ABPxxxxxxxxxA
|
||||||
|
Sensor_Pressure:ABPxxxxxxxxxS
|
||||||
Sensor_Pressure:BMP280
|
Sensor_Pressure:BMP280
|
||||||
Sensor_Pressure:ILPS28QSW
|
Sensor_Pressure:ILPS28QSW
|
||||||
Sensor_Pressure:LPS22DF
|
Sensor_Pressure:LPS22DF
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,8 @@ abstract class BaseAdminController extends AbstractController
|
||||||
|
|
||||||
$this->commentHelper->setMessage($form['log_comment']->getData());
|
$this->commentHelper->setMessage($form['log_comment']->getData());
|
||||||
|
|
||||||
|
//In principle, the form should be disabled, if the edit permission is not granted, but for good measure, we also check it here, before saving changes.
|
||||||
|
$this->denyAccessUnlessGranted('edit', $entity);
|
||||||
$em->persist($entity);
|
$em->persist($entity);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
$this->addFlash('success', 'entity.edit_flash');
|
$this->addFlash('success', 'entity.edit_flash');
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,23 @@ final class PartController extends AbstractController
|
||||||
$this->addFlash('warning', t("part.create_from_info_provider.no_category_yet"));
|
$this->addFlash('warning', t("part.create_from_info_provider.no_category_yet"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$lotAmount = $request->query->get('lotAmount');
|
||||||
|
$lotName = $request->query->get('lotName');
|
||||||
|
$lotUserBarcode = $request->query->get('lotUserBarcode');
|
||||||
|
|
||||||
|
if ($lotAmount !== null || $lotName !== null || $lotUserBarcode !== null) {
|
||||||
|
$partLot = new PartLot();
|
||||||
|
$partLot->setAmount($lotAmount !== null ? (float)$lotAmount : 0);
|
||||||
|
$partLot->setDescription($lotName !== null ? (string)$lotName : '');
|
||||||
|
$partLot->setUserBarcode($lotUserBarcode !== null ? (string)$lotUserBarcode : '');
|
||||||
|
|
||||||
|
$new_part->addPartLot($partLot);
|
||||||
|
|
||||||
|
$this->addFlash('notice', t('part.create_from_info_provider.lot_filled_from_barcode'));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return $this->renderPartForm('new', $request, $new_part, [
|
return $this->renderPartForm('new', $request, $new_part, [
|
||||||
'info_provider_dto' => $dto,
|
'info_provider_dto' => $dto,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ namespace App\Controller;
|
||||||
use App\Entity\UserSystem\User;
|
use App\Entity\UserSystem\User;
|
||||||
use App\Events\SecurityEvent;
|
use App\Events\SecurityEvent;
|
||||||
use App\Events\SecurityEvents;
|
use App\Events\SecurityEvents;
|
||||||
|
use App\Form\Security\LoginFormType;
|
||||||
use App\Services\UserSystem\PasswordResetManager;
|
use App\Services\UserSystem\PasswordResetManager;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Gregwar\CaptchaBundle\Type\CaptchaType;
|
use Gregwar\CaptchaBundle\Type\CaptchaType;
|
||||||
|
|
@ -61,7 +62,12 @@ class SecurityController extends AbstractController
|
||||||
// last username entered by the user
|
// last username entered by the user
|
||||||
$lastUsername = $authenticationUtils->getLastUsername();
|
$lastUsername = $authenticationUtils->getLastUsername();
|
||||||
|
|
||||||
|
$form = $this->createForm(LoginFormType::class, [
|
||||||
|
'_username' => $lastUsername,
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->render('security/login.html.twig', [
|
return $this->render('security/login.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
'last_username' => $lastUsername,
|
'last_username' => $lastUsername,
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,10 @@ class TypeaheadController extends AbstractController
|
||||||
#[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')]
|
#[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')]
|
||||||
public function builtInResources(Request $request, BuiltinAttachmentsFinder $finder): JsonResponse
|
public function builtInResources(Request $request, BuiltinAttachmentsFinder $finder): JsonResponse
|
||||||
{
|
{
|
||||||
$query = $request->get('query');
|
//Ensure that the user can access Part-DB at all
|
||||||
|
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
|
||||||
|
|
||||||
|
$query = $request->query->getString('query');
|
||||||
$array = $finder->find($query);
|
$array = $finder->find($query);
|
||||||
|
|
||||||
$result = [];
|
$result = [];
|
||||||
|
|
@ -205,9 +208,16 @@ class TypeaheadController extends AbstractController
|
||||||
/** @var Category|null $category */
|
/** @var Category|null $category */
|
||||||
$category = $entityManager->getRepository(Category::class)->find($categoryId);
|
$category = $entityManager->getRepository(Category::class)->find($categoryId);
|
||||||
|
|
||||||
|
//Ensure the user has access to both the part and the category
|
||||||
|
$this->denyAccessUnlessGranted('read', $part);
|
||||||
|
if ($category !== null) {
|
||||||
|
$this->denyAccessUnlessGranted('read', $category);
|
||||||
|
}
|
||||||
|
|
||||||
$clonedPart = clone $part;
|
$clonedPart = clone $part;
|
||||||
$clonedPart->setCategory($category);
|
$clonedPart->setCategory($category);
|
||||||
|
|
||||||
|
|
||||||
$partRepository = $entityManager->getRepository(Part::class);
|
$partRepository = $entityManager->getRepository(Part::class);
|
||||||
$ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits);
|
$ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,21 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
use App\Services\System\BackupManager;
|
use App\Services\System\BackupManager;
|
||||||
|
use App\Services\System\InstallationTypeDetector;
|
||||||
use App\Services\System\UpdateChecker;
|
use App\Services\System\UpdateChecker;
|
||||||
use App\Services\System\UpdateExecutor;
|
use App\Services\System\UpdateExecutor;
|
||||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -49,10 +54,14 @@ class UpdateManagerController extends AbstractController
|
||||||
private readonly UpdateExecutor $updateExecutor,
|
private readonly UpdateExecutor $updateExecutor,
|
||||||
private readonly VersionManagerInterface $versionManager,
|
private readonly VersionManagerInterface $versionManager,
|
||||||
private readonly BackupManager $backupManager,
|
private readonly BackupManager $backupManager,
|
||||||
|
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||||
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
|
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
|
||||||
private readonly bool $webUpdatesDisabled = false,
|
private readonly bool $webUpdatesDisabled = false,
|
||||||
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
|
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
|
||||||
private readonly bool $backupRestoreDisabled = false,
|
private readonly bool $backupRestoreDisabled = false,
|
||||||
|
#[Autowire(env: 'bool:DISABLE_BACKUP_DOWNLOAD')]
|
||||||
|
private readonly bool $backupDownloadDisabled = false,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +85,16 @@ class UpdateManagerController extends AbstractController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if backup download is disabled and throw exception if so.
|
||||||
|
*/
|
||||||
|
private function denyIfBackupDownloadDisabled(): void
|
||||||
|
{
|
||||||
|
if ($this->backupDownloadDisabled) {
|
||||||
|
throw new AccessDeniedHttpException('Backup download is disabled by server configuration.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main update manager page.
|
* Main update manager page.
|
||||||
*/
|
*/
|
||||||
|
|
@ -101,6 +120,8 @@ class UpdateManagerController extends AbstractController
|
||||||
'backups' => $this->backupManager->getBackups(),
|
'backups' => $this->backupManager->getBackups(),
|
||||||
'web_updates_disabled' => $this->webUpdatesDisabled,
|
'web_updates_disabled' => $this->webUpdatesDisabled,
|
||||||
'backup_restore_disabled' => $this->backupRestoreDisabled,
|
'backup_restore_disabled' => $this->backupRestoreDisabled,
|
||||||
|
'backup_download_disabled' => $this->backupDownloadDisabled,
|
||||||
|
'is_docker' => $this->installationTypeDetector->isDocker(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,6 +227,7 @@ class UpdateManagerController extends AbstractController
|
||||||
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
|
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
|
||||||
public function startUpdate(Request $request): Response
|
public function startUpdate(Request $request): Response
|
||||||
{
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||||
$this->denyIfWebUpdatesDisabled();
|
$this->denyIfWebUpdatesDisabled();
|
||||||
|
|
||||||
|
|
@ -314,12 +336,126 @@ class UpdateManagerController extends AbstractController
|
||||||
return $this->json($details);
|
return $this->json($details);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a manual backup.
|
||||||
|
*/
|
||||||
|
#[Route('/backup', name: 'admin_update_manager_backup', methods: ['POST'])]
|
||||||
|
public function createBackup(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
|
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('update_manager_backup', $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Invalid CSRF token.');
|
||||||
|
return $this->redirectToRoute('admin_update_manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->updateExecutor->isLocked()) {
|
||||||
|
$this->addFlash('error', 'Cannot create backup while an update is in progress.');
|
||||||
|
return $this->redirectToRoute('admin_update_manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->backupManager->createBackup(null, 'manual');
|
||||||
|
$this->addFlash('success', 'update_manager.backup.created');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->addFlash('error', 'Backup failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_update_manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a backup file.
|
||||||
|
*/
|
||||||
|
#[Route('/backup/delete', name: 'admin_update_manager_backup_delete', methods: ['POST'])]
|
||||||
|
public function deleteBackup(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
|
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Invalid CSRF token.');
|
||||||
|
return $this->redirectToRoute('admin_update_manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = $request->request->get('filename');
|
||||||
|
if ($filename && $this->backupManager->deleteBackup($filename)) {
|
||||||
|
$this->addFlash('success', 'update_manager.backup.deleted');
|
||||||
|
} else {
|
||||||
|
$this->addFlash('error', 'update_manager.backup.delete_error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_update_manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an update log file.
|
||||||
|
*/
|
||||||
|
#[Route('/log/delete', name: 'admin_update_manager_log_delete', methods: ['POST'])]
|
||||||
|
public function deleteLog(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
|
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Invalid CSRF token.');
|
||||||
|
return $this->redirectToRoute('admin_update_manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = $request->request->get('filename');
|
||||||
|
if ($filename && $this->updateExecutor->deleteLog($filename)) {
|
||||||
|
$this->addFlash('success', 'update_manager.log.deleted');
|
||||||
|
} else {
|
||||||
|
$this->addFlash('error', 'update_manager.log.delete_error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_update_manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a backup file.
|
||||||
|
* Requires password confirmation as backups contain sensitive data (password hashes, secrets, etc.).
|
||||||
|
*/
|
||||||
|
#[Route('/backup/download', name: 'admin_update_manager_backup_download', methods: ['POST'])]
|
||||||
|
public function downloadBackup(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
|
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||||
|
$this->denyIfBackupDownloadDisabled();
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('update_manager_download', $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Invalid CSRF token.');
|
||||||
|
return $this->redirectToRoute('admin_update_manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
$password = $request->request->get('password', '');
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User || !$this->passwordHasher->isPasswordValid($user, $password)) {
|
||||||
|
$this->addFlash('error', 'update_manager.backup.download.invalid_password');
|
||||||
|
return $this->redirectToRoute('admin_update_manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = $request->request->get('filename', '');
|
||||||
|
$details = $this->backupManager->getBackupDetails($filename);
|
||||||
|
if (!$details) {
|
||||||
|
throw $this->createNotFoundException('Backup not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new BinaryFileResponse($details['path']);
|
||||||
|
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $details['file']);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore from a backup.
|
* Restore from a backup.
|
||||||
*/
|
*/
|
||||||
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
|
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
|
||||||
public function restore(Request $request): Response
|
public function restore(Request $request): Response
|
||||||
{
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||||
$this->denyIfBackupRestoreDisabled();
|
$this->denyIfBackupRestoreDisabled();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,13 @@ declare(strict_types=1);
|
||||||
namespace App\DataTables;
|
namespace App\DataTables;
|
||||||
|
|
||||||
use App\DataTables\Column\EntityColumn;
|
use App\DataTables\Column\EntityColumn;
|
||||||
|
use App\DataTables\Column\EnumColumn;
|
||||||
use App\DataTables\Column\LocaleDateTimeColumn;
|
use App\DataTables\Column\LocaleDateTimeColumn;
|
||||||
use App\DataTables\Column\MarkdownColumn;
|
use App\DataTables\Column\MarkdownColumn;
|
||||||
use App\DataTables\Helpers\PartDataTableHelper;
|
use App\DataTables\Helpers\PartDataTableHelper;
|
||||||
use App\Entity\Attachments\Attachment;
|
use App\Entity\Attachments\Attachment;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Entity\Parts\ManufacturingStatus;
|
||||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
use App\Services\ElementTypeNameGenerator;
|
use App\Services\ElementTypeNameGenerator;
|
||||||
use App\Services\EntityURLGenerator;
|
use App\Services\EntityURLGenerator;
|
||||||
|
|
@ -145,6 +147,19 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
'orderField' => 'NATSORT(manufacturer.name)',
|
'orderField' => 'NATSORT(manufacturer.name)',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
->add('manufacturing_status', EnumColumn::class, [
|
||||||
|
'label' => $this->translator->trans('part.table.manufacturingStatus'),
|
||||||
|
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
|
||||||
|
'class' => ManufacturingStatus::class,
|
||||||
|
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
|
||||||
|
if ($status === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->translator->trans($status->toTranslationKey());
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
->add('mountnames', TextColumn::class, [
|
->add('mountnames', TextColumn::class, [
|
||||||
'label' => 'project.bom.mountnames',
|
'label' => 'project.bom.mountnames',
|
||||||
'render' => function ($value, ProjectBOMEntry $context) {
|
'render' => function ($value, ProjectBOMEntry $context) {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
#[ORM\Table(name: 'part_lots')]
|
#[ORM\Table(name: 'part_lots')]
|
||||||
#[ORM\Index(columns: ['instock_unknown', 'expiration_date', 'id_part'], name: 'part_lots_idx_instock_un_expiration_id_part')]
|
#[ORM\Index(columns: ['instock_unknown', 'expiration_date', 'id_part'], name: 'part_lots_idx_instock_un_expiration_id_part')]
|
||||||
#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')]
|
#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')]
|
||||||
#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')]
|
#[ORM\Index(name: 'part_lots_idx_barcode', columns: ['vendor_barcode'], options: ['lengths' => [100]])]
|
||||||
#[ValidPartLot]
|
#[ValidPartLot]
|
||||||
#[UniqueEntity(['user_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
|
#[UniqueEntity(['user_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
|
|
@ -81,7 +81,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
denormalizationContext: ['groups' => ['part_lot:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
|
denormalizationContext: ['groups' => ['part_lot:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
|
||||||
)]
|
)]
|
||||||
#[ApiFilter(PropertyFilter::class)]
|
#[ApiFilter(PropertyFilter::class)]
|
||||||
#[ApiFilter(LikeFilter::class, properties: ["description", "comment"])]
|
#[ApiFilter(LikeFilter::class, properties: ["description", "comment", "user_barcode"])]
|
||||||
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
|
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
|
||||||
#[ApiFilter(BooleanFilter::class, properties: ['instock_unknown', 'needs_refill'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['instock_unknown', 'needs_refill'])]
|
||||||
#[ApiFilter(RangeFilter::class, properties: ['amount'])]
|
#[ApiFilter(RangeFilter::class, properties: ['amount'])]
|
||||||
|
|
@ -166,9 +166,8 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
|
||||||
/**
|
/**
|
||||||
* @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor)
|
* @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor)
|
||||||
*/
|
*/
|
||||||
#[ORM\Column(name: "vendor_barcode", type: Types::STRING, nullable: true)]
|
#[ORM\Column(name: "vendor_barcode", type: Types::TEXT, nullable: true)]
|
||||||
#[Groups(['part_lot:read', 'part_lot:write'])]
|
#[Groups(['part_lot:read', 'part_lot:write'])]
|
||||||
#[Length(max: 255)]
|
|
||||||
protected ?string $user_barcode = null;
|
protected ?string $user_barcode = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ class BaseEntityAdminForm extends AbstractType
|
||||||
'label' => 'entity.edit.alternative_names.label',
|
'label' => 'entity.edit.alternative_names.label',
|
||||||
'help' => 'entity.edit.alternative_names.help',
|
'help' => 'entity.edit.alternative_names.help',
|
||||||
'empty_data' => null,
|
'empty_data' => null,
|
||||||
|
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
|
||||||
'attr' => [
|
'attr' => [
|
||||||
'class' => 'tagsinput',
|
'class' => 'tagsinput',
|
||||||
'data-controller' => 'elements--tagsinput',
|
'data-controller' => 'elements--tagsinput',
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ final class TogglePasswordTypeExtension extends AbstractTypeExtension
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
'toggle' => false,
|
'toggle' => true,
|
||||||
'hidden_label' => new TranslatableMessage('password_toggle.hide'),
|
'hidden_label' => new TranslatableMessage('password_toggle.hide'),
|
||||||
'visible_label' => new TranslatableMessage('password_toggle.show'),
|
'visible_label' => new TranslatableMessage('password_toggle.show'),
|
||||||
'hidden_icon' => 'Default',
|
'hidden_icon' => 'Default',
|
||||||
|
|
|
||||||
83
src/Form/Security/LoginFormType.php
Normal file
83
src/Form/Security/LoginFormType.php
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<?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\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
use function Symfony\Component\Translation\t;
|
||||||
|
|
||||||
|
class LoginFormType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('_username', TextType::class, [
|
||||||
|
'label' => t('login.username.label'),
|
||||||
|
'attr' => [
|
||||||
|
'autofocus' => 'autofocus',
|
||||||
|
'autocomplete' => 'username',
|
||||||
|
'placeholder' => t('login.username.placeholder'),
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->add('_password', PasswordType::class, [
|
||||||
|
'label' => t('login.password.label'),
|
||||||
|
'attr' => [
|
||||||
|
'autocomplete' => 'current-password',
|
||||||
|
'placeholder' => t('login.password.placeholder'),
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->add('_remember_me', CheckboxType::class, [
|
||||||
|
'label' => t('login.rememberme'),
|
||||||
|
'required' => false,
|
||||||
|
])
|
||||||
|
->add('submit', \Symfony\Component\Form\Extension\Core\Type\SubmitType::class, [
|
||||||
|
'label' => t('login.btn'),
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
// This ensures CSRF protection is active for the login
|
||||||
|
'csrf_protection' => true,
|
||||||
|
'csrf_field_name' => '_csrf_token',
|
||||||
|
'csrf_token_id' => 'authenticate',
|
||||||
|
'attr' => [
|
||||||
|
'data-turbo' => 'false', // Disable Turbo for the login form to ensure proper redirection after login
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBlockPrefix(): string
|
||||||
|
{
|
||||||
|
// This removes the "login_form_" prefix from field names
|
||||||
|
// so that Security can find "_username" directly.
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,8 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
|
||||||
|
|
||||||
private const ALREADY_CALLED = 'STRUCTURAL_DENORMALIZER_ALREADY_CALLED';
|
private const ALREADY_CALLED = 'STRUCTURAL_DENORMALIZER_ALREADY_CALLED';
|
||||||
|
|
||||||
|
private const PARENT_ELEMENT = 'STRUCTURAL_DENORMALIZER_PARENT_ELEMENT';
|
||||||
|
|
||||||
private array $object_cache = [];
|
private array $object_cache = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|
@ -89,37 +91,59 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
|
||||||
|
|
||||||
$context[self::ALREADY_CALLED][] = $data;
|
$context[self::ALREADY_CALLED][] = $data;
|
||||||
|
|
||||||
|
//In the first step, denormalize without children
|
||||||
|
$context_without_children = $context;
|
||||||
|
$context_without_children['groups'] = array_filter(
|
||||||
|
$context_without_children['groups'] ?? [],
|
||||||
|
static fn($group) => $group !== 'include_children',
|
||||||
|
);
|
||||||
|
//Also unset any parent element, to avoid infinite loops. We will set the parent element in the next step, when we denormalize the children
|
||||||
|
unset($context_without_children[self::PARENT_ELEMENT]);
|
||||||
|
/** @var AbstractStructuralDBElement $entity */
|
||||||
|
$entity = $this->denormalizer->denormalize($data, $type, $format, $context_without_children);
|
||||||
|
|
||||||
/** @var AbstractStructuralDBElement $deserialized_entity */
|
//Assign the parent element to the denormalized entity, so it can be used in the denormalization of the children (e.g. for path generation)
|
||||||
$deserialized_entity = $this->denormalizer->denormalize($data, $type, $format, $context);
|
if (isset($context[self::PARENT_ELEMENT]) && $context[self::PARENT_ELEMENT] instanceof $entity && $entity->getID() === null) {
|
||||||
|
$entity->setParent($context[self::PARENT_ELEMENT]);
|
||||||
|
}
|
||||||
|
|
||||||
//Check if we already have the entity in the database (via path)
|
//Check if we already have the entity in the database (via path)
|
||||||
/** @var StructuralDBElementRepository<T> $repo */
|
/** @var StructuralDBElementRepository<T> $repo */
|
||||||
$repo = $this->entityManager->getRepository($type);
|
$repo = $this->entityManager->getRepository($type);
|
||||||
|
|
||||||
$path = $deserialized_entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
|
$path = $entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
|
||||||
$db_elements = $repo->getEntityByPath($path, AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
|
$db_elements = $repo->getEntityByPath($path, AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
|
||||||
if ($db_elements !== []) {
|
if ($db_elements !== []) {
|
||||||
//We already have the entity in the database, so we can return it
|
//We already have the entity in the database, so we can return it
|
||||||
return end($db_elements);
|
$entity = end($db_elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//Check if we have created the entity in this request before (so we don't create multiple entities for the same path)
|
//Check if we have created the entity in this request before (so we don't create multiple entities for the same path)
|
||||||
//Entities get saved in the cache by type and path
|
//Entities get saved in the cache by type and path
|
||||||
//We use a different cache for this then the objects created by a string value (saved in repo). However, that should not be a problem
|
//We use a different cache for this then the objects created by a string value (saved in repo). However, that should not be a problem
|
||||||
//unless the user data has mixed structure between json data and a string path
|
//unless the user data has mixed structure between JSON data and a string path
|
||||||
if (isset($this->object_cache[$type][$path])) {
|
if (isset($this->object_cache[$type][$path])) {
|
||||||
return $this->object_cache[$type][$path];
|
$entity = $this->object_cache[$type][$path];
|
||||||
|
} else {
|
||||||
|
//Save the entity in the cache
|
||||||
|
$this->object_cache[$type][$path] = $entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Save the entity in the cache
|
//In the next step we can denormalize the children, and add our children to the entity.
|
||||||
$this->object_cache[$type][$path] = $deserialized_entity;
|
if (in_array('include_children', $context['groups'], true) && isset($data['children']) && is_array($data['children'])) {
|
||||||
|
foreach ($data['children'] as $child_data) {
|
||||||
|
$child_entity = $this->denormalize($child_data, $type, $format, array_merge($context, [self::PARENT_ELEMENT => $entity]));
|
||||||
|
if ($child_entity !== null && !$entity->getChildren()->contains($child_entity)) {
|
||||||
|
$entity->addChild($child_entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//We don't have the entity in the database, so we have to persist it
|
//We don't have the entity in the database, so we have to persist it
|
||||||
$this->entityManager->persist($deserialized_entity);
|
$this->entityManager->persist($entity);
|
||||||
|
|
||||||
return $deserialized_entity;
|
return $entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSupportedTypes(?string $format): array
|
public function getSupportedTypes(?string $format): array
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,7 @@ class KiCadHelper
|
||||||
"exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false),
|
"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_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false),
|
||||||
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false),
|
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false),
|
||||||
|
"description" => $part->getDescription(),
|
||||||
"fields" => []
|
"fields" => []
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -219,11 +219,6 @@ class EntityImporter
|
||||||
$entities = [$entities];
|
$entities = [$entities];
|
||||||
}
|
}
|
||||||
|
|
||||||
//The serializer has only set the children attributes. We also have to change the parent value (the real value in DB)
|
|
||||||
if ($entities[0] instanceof AbstractStructuralDBElement) {
|
|
||||||
$this->correctParentEntites($entities, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Set the parent of the imported elements to the given options
|
//Set the parent of the imported elements to the given options
|
||||||
foreach ($entities as $entity) {
|
foreach ($entities as $entity) {
|
||||||
if ($entity instanceof AbstractStructuralDBElement) {
|
if ($entity instanceof AbstractStructuralDBElement) {
|
||||||
|
|
@ -297,6 +292,14 @@ class EntityImporter
|
||||||
return $resolver;
|
return $resolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function persistRecursively(AbstractStructuralDBElement $entity): void
|
||||||
|
{
|
||||||
|
$this->em->persist($entity);
|
||||||
|
foreach ($entity->getChildren() as $child) {
|
||||||
|
$this->persistRecursively($child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method deserializes the given file and writes the entities to the database (and flush the db).
|
* This method deserializes the given file and writes the entities to the database (and flush the db).
|
||||||
* The imported elements will be checked (validated) before written to database.
|
* The imported elements will be checked (validated) before written to database.
|
||||||
|
|
@ -322,7 +325,11 @@ class EntityImporter
|
||||||
|
|
||||||
//Iterate over each $entity write it to DB (the invalid entities were already filtered out).
|
//Iterate over each $entity write it to DB (the invalid entities were already filtered out).
|
||||||
foreach ($entities as $entity) {
|
foreach ($entities as $entity) {
|
||||||
$this->em->persist($entity);
|
if ($entity instanceof AbstractStructuralDBElement) {
|
||||||
|
$this->persistRecursively($entity);
|
||||||
|
} else {
|
||||||
|
$this->em->persist($entity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Save changes to database, when no error happened, or we should continue on error.
|
//Save changes to database, when no error happened, or we should continue on error.
|
||||||
|
|
@ -484,21 +491,4 @@ class EntityImporter
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This functions corrects the parent setting based on the children value of the parent.
|
|
||||||
*
|
|
||||||
* @param iterable $entities the list of entities that should be fixed
|
|
||||||
* @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set
|
|
||||||
*/
|
|
||||||
protected function correctParentEntites(iterable $entities, ?AbstractStructuralDBElement $parent = null): void
|
|
||||||
{
|
|
||||||
foreach ($entities as $entity) {
|
|
||||||
/** @var AbstractStructuralDBElement $entity */
|
|
||||||
$entity->setParent($parent);
|
|
||||||
//Do the same for the children of entity
|
|
||||||
$this->correctParentEntites($entity->getChildren(), $entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,8 @@ final readonly class BarcodeScanResultHandler
|
||||||
throw InfoProviderNotActiveException::fromProvider($provider);
|
throw InfoProviderNotActiveException::fromProvider($provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->urlGenerator->generate('info_providers_create_part', ['providerKey' => $infos['providerKey'], 'providerId' => $infos['providerId']]);
|
//So far we can just copy over our provider info array to the URL parameters:
|
||||||
|
return $this->urlGenerator->generate('info_providers_create_part', $infos);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -146,7 +147,7 @@ final readonly class BarcodeScanResultHandler
|
||||||
|
|
||||||
if ($barcodeScan instanceof AmazonBarcodeScanResult) {
|
if ($barcodeScan instanceof AmazonBarcodeScanResult) {
|
||||||
return $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->asin)
|
return $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->asin)
|
||||||
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
|
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -246,7 +247,7 @@ final readonly class BarcodeScanResultHandler
|
||||||
* Returns null if no provider information could be extracted from the scan result, or if the scan result type is unknown and cannot be handled by this function.
|
* Returns null if no provider information could be extracted from the scan result, or if the scan result type is unknown and cannot be handled by this function.
|
||||||
* It is not necessarily checked that the provider is active, or that the result actually exists on the provider side.
|
* It is not necessarily checked that the provider is active, or that the result actually exists on the provider side.
|
||||||
* @param BarcodeScanResultInterface $scanResult
|
* @param BarcodeScanResultInterface $scanResult
|
||||||
* @return array{providerKey: string, providerId: string}|null
|
* @return array{providerKey: string, providerId: string, lotAmount?: float|int, lotName?: string, lotUserBarcode?: string}|null
|
||||||
* @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system
|
* @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system
|
||||||
*/
|
*/
|
||||||
public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array
|
public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array
|
||||||
|
|
@ -256,6 +257,9 @@ final readonly class BarcodeScanResultHandler
|
||||||
return [
|
return [
|
||||||
'providerKey' => 'lcsc',
|
'providerKey' => 'lcsc',
|
||||||
'providerId' => $scanResult->lcscCode,
|
'providerId' => $scanResult->lcscCode,
|
||||||
|
'lotAmount' => $scanResult->quantity,
|
||||||
|
'lotName' => $scanResult->orderNumber ?? $scanResult->pickBatchNumber,
|
||||||
|
'lotUserBarcode' => $scanResult->rawInput,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +280,7 @@ final readonly class BarcodeScanResultHandler
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param EIGP114BarcodeScanResult $scanResult
|
* @param EIGP114BarcodeScanResult $scanResult
|
||||||
* @return array{providerKey: string, providerId: string}|null
|
* * @return array{providerKey: string, providerId: string, lotAmount?: float|int, lotName?: string, lotUserBarcode?: string}|null
|
||||||
*/
|
*/
|
||||||
private function getCreationInfoForEIGP114(EIGP114BarcodeScanResult $scanResult): ?array
|
private function getCreationInfoForEIGP114(EIGP114BarcodeScanResult $scanResult): ?array
|
||||||
{
|
{
|
||||||
|
|
@ -285,30 +289,36 @@ final readonly class BarcodeScanResultHandler
|
||||||
// Mouser: use supplierPartNumber -> search provider -> provider_id
|
// Mouser: use supplierPartNumber -> search provider -> provider_id
|
||||||
if ($vendor === 'mouser' && $scanResult->supplierPartNumber !== null
|
if ($vendor === 'mouser' && $scanResult->supplierPartNumber !== null
|
||||||
) {
|
) {
|
||||||
// Search Mouser using the MPN
|
// Search Mouser using the MPN
|
||||||
$dtos = $this->infoRetriever->searchByKeyword(
|
$dtos = $this->infoRetriever->searchByKeyword(
|
||||||
keyword: $scanResult->supplierPartNumber,
|
keyword: $scanResult->supplierPartNumber,
|
||||||
providers: ["mouser"]
|
providers: ["mouser"]
|
||||||
);
|
);
|
||||||
|
|
||||||
// If there are results, provider_id is MouserPartNumber (per MouserProvider.php)
|
// If there are results, provider_id is MouserPartNumber (per MouserProvider.php)
|
||||||
$best = $dtos[0] ?? null;
|
$best = $dtos[0] ?? null;
|
||||||
|
|
||||||
if ($best !== null) {
|
if ($best !== null) {
|
||||||
return [
|
return [
|
||||||
'providerKey' => 'mouser',
|
'providerKey' => 'mouser',
|
||||||
'providerId' => $best->provider_id,
|
'providerId' => $best->provider_id,
|
||||||
];
|
'lotAmount' => $scanResult->quantity,
|
||||||
}
|
'lotName' => $scanResult->customerPO,
|
||||||
|
'lotUserBarcode' => $scanResult->rawInput,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Digi-Key: can use customerPartNumber or supplierPartNumber directly
|
// Digi-Key: supplierPartNumber directly
|
||||||
if ($vendor === 'digikey') {
|
if ($vendor === 'digikey') {
|
||||||
return [
|
return [
|
||||||
'providerKey' => 'digikey',
|
'providerKey' => 'digikey',
|
||||||
'providerId' => $scanResult->customerPartNumber ?? $scanResult->supplierPartNumber,
|
'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Digikey barcode does not contain required supplier part number'),
|
||||||
|
'lotAmount' => $scanResult->quantity,
|
||||||
|
'lotName' => $scanResult->digikeyInvoiceNumber ?? $scanResult->digikeySalesOrderNumber ?? $scanResult->customerPO,
|
||||||
|
'lotUserBarcode' => $scanResult->rawInput,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,7 +326,10 @@ final readonly class BarcodeScanResultHandler
|
||||||
if ($vendor === 'element14') {
|
if ($vendor === 'element14') {
|
||||||
return [
|
return [
|
||||||
'providerKey' => 'element14',
|
'providerKey' => 'element14',
|
||||||
'providerId' => $scanResult->supplierPartNumber,
|
'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Element14 barcode does not contain required supplier part number'),
|
||||||
|
'lotAmount' => $scanResult->quantity,
|
||||||
|
'lotName' => $scanResult->customerPO,
|
||||||
|
'lotUserBarcode' => $scanResult->rawInput,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||||
*
|
*
|
||||||
* @param array<string, string> $data The fields of the EIGP114 barcode, where the key is the field name and the value is the field content
|
* @param array<string, string> $data The fields of the EIGP114 barcode, where the key is the field name and the value is the field content
|
||||||
*/
|
*/
|
||||||
public function __construct(public array $data)
|
public function __construct(public array $data, public readonly ?string $rawInput = null)
|
||||||
{
|
{
|
||||||
//IDs per EIGP 114.2018
|
//IDs per EIGP 114.2018
|
||||||
$this->shipDate = $data['6D'] ?? null;
|
$this->shipDate = $data['6D'] ?? null;
|
||||||
|
|
@ -271,6 +271,8 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||||
*/
|
*/
|
||||||
public static function parseFormat06Code(string $input): self
|
public static function parseFormat06Code(string $input): self
|
||||||
{
|
{
|
||||||
|
$rawInput = $input;
|
||||||
|
|
||||||
//Ensure that the input is a valid format06 code
|
//Ensure that the input is a valid format06 code
|
||||||
if (!self::isFormat06Code($input)) {
|
if (!self::isFormat06Code($input)) {
|
||||||
throw new \InvalidArgumentException("The given input is not a valid format06 code");
|
throw new \InvalidArgumentException("The given input is not a valid format06 code");
|
||||||
|
|
@ -306,7 +308,7 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||||
$results[$key] = $fieldValue;
|
$results[$key] = $fieldValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new self($results);
|
return new self($results, $rawInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDecodedForInfoMode(): array
|
public function getDecodedForInfoMode(): array
|
||||||
|
|
|
||||||
|
|
@ -114,10 +114,12 @@ final class BarcodeProvider implements PlaceholderProviderInterface
|
||||||
return '<b>IPN Barcode ERROR!</b>: '.$e->getMessage();
|
return '<b>IPN Barcode ERROR!</b>: '.$e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getDefaultPriority(): int
|
||||||
|
{
|
||||||
|
//This provider should be checked before all others, so that nothing is delegated for part lots
|
||||||
|
return 1000;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -327,14 +327,14 @@ readonly class BackupManager
|
||||||
*/
|
*/
|
||||||
private function restoreDatabaseFromBackup(string $tempDir): void
|
private function restoreDatabaseFromBackup(string $tempDir): void
|
||||||
{
|
{
|
||||||
|
// Get database connection params from Doctrine
|
||||||
|
$connection = $this->entityManager->getConnection();
|
||||||
|
$params = $connection->getParams();
|
||||||
|
$platform = $connection->getDatabasePlatform();
|
||||||
|
|
||||||
// Check for SQL dump (MySQL/PostgreSQL)
|
// Check for SQL dump (MySQL/PostgreSQL)
|
||||||
$sqlFile = $tempDir . '/database.sql';
|
$sqlFile = $tempDir . '/database.sql';
|
||||||
if (file_exists($sqlFile)) {
|
if (file_exists($sqlFile)) {
|
||||||
// Import SQL using mysql/psql command directly
|
|
||||||
// First, get database connection params from Doctrine
|
|
||||||
$connection = $this->entityManager->getConnection();
|
|
||||||
$params = $connection->getParams();
|
|
||||||
$platform = $connection->getDatabasePlatform();
|
|
||||||
|
|
||||||
if ($platform instanceof AbstractMySQLPlatform) {
|
if ($platform instanceof AbstractMySQLPlatform) {
|
||||||
// Use mysql command to import - need to use shell to handle input redirection
|
// Use mysql command to import - need to use shell to handle input redirection
|
||||||
|
|
@ -403,7 +403,8 @@ readonly class BackupManager
|
||||||
// Check for SQLite database file
|
// Check for SQLite database file
|
||||||
$sqliteFile = $tempDir . '/var/app.db';
|
$sqliteFile = $tempDir . '/var/app.db';
|
||||||
if (file_exists($sqliteFile)) {
|
if (file_exists($sqliteFile)) {
|
||||||
$targetDb = $this->projectDir . '/var/app.db';
|
// Use the actual configured SQLite path from Doctrine, not a hardcoded path
|
||||||
|
$targetDb = $params['path'] ?? $this->projectDir . '/var/app.db';
|
||||||
$this->filesystem->copy($sqliteFile, $targetDb, true);
|
$this->filesystem->copy($sqliteFile, $targetDb, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,79 @@ class UpdateExecutor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset PHP OPcache for the web server process.
|
||||||
|
*
|
||||||
|
* OPcache in PHP-FPM is separate from CLI. After updating code files,
|
||||||
|
* PHP-FPM may still serve stale cached bytecode, causing constructor
|
||||||
|
* mismatches and 500 errors. This method creates a temporary PHP script
|
||||||
|
* in the public directory, invokes it via HTTP to reset OPcache in the
|
||||||
|
* web server context, then removes the script.
|
||||||
|
*
|
||||||
|
* @return bool Whether OPcache was successfully reset
|
||||||
|
*/
|
||||||
|
private function resetOpcache(): bool
|
||||||
|
{
|
||||||
|
$token = bin2hex(random_bytes(16));
|
||||||
|
$resetScript = $this->project_dir . '/public/_opcache_reset_' . $token . '.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a temporary PHP script that resets OPcache
|
||||||
|
$scriptContent = '<?php '
|
||||||
|
. 'if (function_exists("opcache_reset")) { opcache_reset(); echo "OK"; } '
|
||||||
|
. 'else { echo "NO_OPCACHE"; } '
|
||||||
|
. '@unlink(__FILE__);';
|
||||||
|
|
||||||
|
$this->filesystem->dumpFile($resetScript, $scriptContent);
|
||||||
|
|
||||||
|
// Try to invoke it via HTTP on localhost
|
||||||
|
$urls = [
|
||||||
|
'http://127.0.0.1/_opcache_reset_' . $token . '.php',
|
||||||
|
'http://localhost/_opcache_reset_' . $token . '.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
$success = false;
|
||||||
|
foreach ($urls as $url) {
|
||||||
|
try {
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'timeout' => 5,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = @file_get_contents($url, false, $context);
|
||||||
|
if ($response === 'OK') {
|
||||||
|
$this->logger->info('OPcache reset via ' . $url);
|
||||||
|
$success = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Try next URL
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$success) {
|
||||||
|
$this->logger->info('OPcache reset via HTTP not available, trying CLI fallback');
|
||||||
|
// CLI opcache_reset() only affects CLI, but try anyway
|
||||||
|
if (function_exists('opcache_reset')) {
|
||||||
|
opcache_reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $success;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->warning('OPcache reset failed: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
// Ensure the temp script is removed
|
||||||
|
if (file_exists($resetScript)) {
|
||||||
|
@unlink($resetScript);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that we can perform an update.
|
* Validate that we can perform an update.
|
||||||
*
|
*
|
||||||
|
|
@ -420,7 +493,7 @@ class UpdateExecutor
|
||||||
// Step 11: Clear cache
|
// Step 11: Clear cache
|
||||||
$stepStart = microtime(true);
|
$stepStart = microtime(true);
|
||||||
$this->runCommand([
|
$this->runCommand([
|
||||||
'php', 'bin/console', 'cache:clear',
|
'php', 'bin/console', 'cache:pool:clear', '--all',
|
||||||
'--env=prod',
|
'--env=prod',
|
||||||
'--no-interaction',
|
'--no-interaction',
|
||||||
], 'Clear cache', 120);
|
], 'Clear cache', 120);
|
||||||
|
|
@ -434,12 +507,20 @@ class UpdateExecutor
|
||||||
], 'Warmup cache', 120);
|
], 'Warmup cache', 120);
|
||||||
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
||||||
|
|
||||||
// Step 13: Disable maintenance mode
|
// Step 13: Reset OPcache (if available)
|
||||||
|
$stepStart = microtime(true);
|
||||||
|
$opcacheResult = $this->resetOpcache();
|
||||||
|
$log('opcache_reset', $opcacheResult
|
||||||
|
? 'Reset PHP OPcache for web server'
|
||||||
|
: 'OPcache reset skipped (not available or not needed)',
|
||||||
|
true, microtime(true) - $stepStart);
|
||||||
|
|
||||||
|
// Step 14: Disable maintenance mode
|
||||||
$stepStart = microtime(true);
|
$stepStart = microtime(true);
|
||||||
$this->disableMaintenanceMode();
|
$this->disableMaintenanceMode();
|
||||||
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
||||||
|
|
||||||
// Step 14: Release lock
|
// Step 15: Release lock
|
||||||
$stepStart = microtime(true);
|
$stepStart = microtime(true);
|
||||||
$this->releaseLock();
|
$this->releaseLock();
|
||||||
|
|
||||||
|
|
@ -489,11 +570,14 @@ class UpdateExecutor
|
||||||
|
|
||||||
// Clear cache after rollback
|
// Clear cache after rollback
|
||||||
$this->runCommand([
|
$this->runCommand([
|
||||||
'php', 'bin/console', 'cache:clear',
|
'php', 'bin/console', 'cache:pool:clear', '--all',
|
||||||
'--env=prod',
|
'--env=prod',
|
||||||
], 'Clear cache after rollback', 120);
|
], 'Clear cache after rollback', 120);
|
||||||
$log('rollback_cache', 'Cleared cache after rollback', true);
|
$log('rollback_cache', 'Cleared cache after rollback', true);
|
||||||
|
|
||||||
|
// Reset OPcache after rollback
|
||||||
|
$this->resetOpcache();
|
||||||
|
|
||||||
} catch (\Exception $rollbackError) {
|
} catch (\Exception $rollbackError) {
|
||||||
$log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false);
|
$log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false);
|
||||||
}
|
}
|
||||||
|
|
@ -602,6 +686,33 @@ class UpdateExecutor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific update log file.
|
||||||
|
*/
|
||||||
|
public function deleteLog(string $filename): bool
|
||||||
|
{
|
||||||
|
// Validate filename pattern for security
|
||||||
|
if (!preg_match('/^update-[\w.\-]+\.log$/', $filename)) {
|
||||||
|
$this->logger->warning('Attempted to delete invalid log filename: ' . $filename);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logPath = $this->project_dir . '/' . self::UPDATE_LOG_DIR . '/' . basename($filename);
|
||||||
|
|
||||||
|
if (!file_exists($logPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->filesystem->remove($logPath);
|
||||||
|
$this->logger->info('Deleted update log: ' . $filename);
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Failed to delete update log: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore from a backup file with maintenance mode and cache clearing.
|
* Restore from a backup file with maintenance mode and cache clearing.
|
||||||
*
|
*
|
||||||
|
|
@ -682,12 +793,17 @@ class UpdateExecutor
|
||||||
$this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache');
|
$this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache');
|
||||||
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
||||||
|
|
||||||
// Step 6: Disable maintenance mode
|
// Step 6: Reset OPcache
|
||||||
|
$stepStart = microtime(true);
|
||||||
|
$this->resetOpcache();
|
||||||
|
$log('opcache_reset', 'Reset PHP OPcache', true, microtime(true) - $stepStart);
|
||||||
|
|
||||||
|
// Step 7: Disable maintenance mode
|
||||||
$stepStart = microtime(true);
|
$stepStart = microtime(true);
|
||||||
$this->disableMaintenanceMode();
|
$this->disableMaintenanceMode();
|
||||||
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
||||||
|
|
||||||
// Step 7: Release lock
|
// Step 8: Release lock
|
||||||
$this->releaseLock();
|
$this->releaseLock();
|
||||||
|
|
||||||
$totalDuration = microtime(true) - $startTime;
|
$totalDuration = microtime(true) - $startTime;
|
||||||
|
|
@ -817,7 +933,7 @@ class UpdateExecutor
|
||||||
'create_backup' => $createBackup,
|
'create_backup' => $createBackup,
|
||||||
'started_at' => (new \DateTime())->format('c'),
|
'started_at' => (new \DateTime())->format('c'),
|
||||||
'current_step' => 0,
|
'current_step' => 0,
|
||||||
'total_steps' => 14,
|
'total_steps' => 15,
|
||||||
'step_name' => 'initializing',
|
'step_name' => 'initializing',
|
||||||
'step_message' => 'Starting update process...',
|
'step_message' => 'Starting update process...',
|
||||||
'steps' => [],
|
'steps' => [],
|
||||||
|
|
@ -890,7 +1006,7 @@ class UpdateExecutor
|
||||||
bool $createBackup = true,
|
bool $createBackup = true,
|
||||||
?callable $onProgress = null
|
?callable $onProgress = null
|
||||||
): array {
|
): array {
|
||||||
$totalSteps = 12;
|
$totalSteps = 13;
|
||||||
$currentStep = 0;
|
$currentStep = 0;
|
||||||
|
|
||||||
$updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void {
|
$updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void {
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,19 @@
|
||||||
{% block flashes %}
|
{% block flashes %}
|
||||||
{# Insert flashes #}
|
{# Insert flashes #}
|
||||||
<turbo-stream action="replace" action="morph" target="toast-container">
|
<turbo-stream action="update" action="morph" target="toast-container">
|
||||||
<template>
|
<template>
|
||||||
<div class="toast-container" id="toast-container">
|
{% for label, messages in app.flashes() %}
|
||||||
{% for label, messages in app.flashes() %}
|
{% for message in messages %}
|
||||||
{% for message in messages %}
|
{{ include('_toast.html.twig', {
|
||||||
{{ include('_toast.html.twig', {
|
'label': label,
|
||||||
'label': label,
|
'message': message
|
||||||
'message': message
|
}) }}
|
||||||
}) }}
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
{% endfor %}
|
||||||
</template>
|
</template>
|
||||||
</turbo-stream>
|
</turbo-stream>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{# Insert info about when the sidebar trees were updated last time, so the sidebar_tree_controller can decide if it needs to reload the tree #}
|
{# Insert info about when the sidebar trees were updated last time, so the sidebar_tree_controller can decide if it needs to reload the tree #}
|
||||||
<span id="sidebar-last-time-updated" style="display: none;" data-last-update="{{ sidebar_tree_updater.lastTreeUpdate.format("Y-m-d\\TH:i:sP") }}"></span>
|
<span id="sidebar-last-time-updated" style="display: none;" data-last-update="{{ sidebar_tree_updater.lastTreeUpdate.format("Y-m-d\\TH:i:sP") }}"></span>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ entity.id) }}">
|
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ entity.id) }}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class=""></div>
|
<div class=""></div>
|
||||||
<div class="col-sm offset-sm-3 ps-2">
|
<div class="{{ col_input }} {{ offset_label }} ps-1">
|
||||||
{% set delete_disabled = (not is_granted("delete", entity)) or (entity.group is defined and entity.id == 1) or entity == app.user %}
|
{% set delete_disabled = (not is_granted("delete", entity)) or (entity.group is defined and entity.id == 1) or entity == app.user %}
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-danger" {% if delete_disabled %}disabled{% endif %}>{% trans %}entity.delete{% endtrans %}</button>
|
<button class="btn btn-danger" {% if delete_disabled %}disabled{% endif %}>{% trans %}entity.delete{% endtrans %}</button>
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if entity.parent is defined %}
|
{% if entity.parent is defined %}
|
||||||
<div class="ms-2 custom-control custom-checkbox custom-control-inline">
|
<div class="ms-1 custom-control custom-checkbox custom-control-inline">
|
||||||
<input type="checkbox" class="form-check-input" id="recursive" name="delete_recursive">
|
<input type="checkbox" class="form-check-input" id="recursive" name="delete_recursive">
|
||||||
<label class="form-check-label" for="recursive">{% trans %}entity.delete.recursive{% endtrans %}</label>
|
<label class="form-check-label" for="recursive">{% trans %}entity.delete.recursive{% endtrans %}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="offset-3 col">
|
<div class="{{ offset_label }} {{ col_input }}">
|
||||||
<a class="btn btn-info {% if not is_granted('create', entity) %}disabled{% endif %}" href="{{ entity_url(entity, 'clone') }}">{% trans %}entity.duplicate{% endtrans %}</a>
|
<a class="btn btn-info {% if not is_granted('create', entity) %}disabled{% endif %}" href="{{ entity_url(entity, 'clone') }}">{% trans %}entity.duplicate{% endtrans %}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mt-2">
|
<div class="row mt-2">
|
||||||
<div class="offset-sm-3 col-sm">
|
<div class="{{ offset_label }} col-sm">
|
||||||
<button type="submit" class="btn btn-primary">{% trans %}export.btn{% endtrans %}</button>
|
<button type="submit" class="btn btn-primary">{% trans %}export.btn{% endtrans %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
{{ form_widget(form.save) }}
|
{{ form_widget(form.save) }}
|
||||||
<button type="button" class="btn {% if entity.id is not null %}btn-primary{% else %}btn-success{% endif %} dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<button type="button" class="btn {% if entity.id is not null %}btn-primary{% else %}btn-success{% endif %} dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
|
@ -186,7 +186,7 @@
|
||||||
|
|
||||||
<div id="mass_creation" class="tab-pane fade">
|
<div id="mass_creation" class="tab-pane fade">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<p class="text-muted offset-sm-3 col-sm-9">{% trans %}mass_creation.help{% endtrans %}</p>
|
<p class="text-muted {{ offset_label }} {{ col_input }}">{% trans %}mass_creation.help{% endtrans %}</p>
|
||||||
</div>
|
</div>
|
||||||
{{ form(mass_creation_form) }}
|
{{ form(mass_creation_form) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -195,9 +195,6 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{# Include turbo control things, so we can still control page title and reloading #}
|
|
||||||
{% include "_turbo_control.html.twig" %}
|
|
||||||
</turbo-frame>
|
</turbo-frame>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,13 @@
|
||||||
{{ form_row(form.eda_info.reference_prefix) }}
|
{{ form_row(form.eda_info.reference_prefix) }}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
{{ form_row(form.eda_info.visibility) }}
|
{{ form_row(form.eda_info.visibility) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
{{ form_widget(form.eda_info.exclude_from_bom) }}
|
{{ form_widget(form.eda_info.exclude_from_bom) }}
|
||||||
{{ form_widget(form.eda_info.exclude_from_board) }}
|
{{ form_widget(form.eda_info.exclude_from_board) }}
|
||||||
{{ form_widget(form.eda_info.exclude_from_sim) }}
|
{{ form_widget(form.eda_info.exclude_from_sim) }}
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
<h6>{% trans %}eda_info.kicad_section.title{% endtrans %}:</h6>
|
<h6>{% trans %}eda_info.kicad_section.title{% endtrans %}:</h6>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@
|
||||||
{{ form_row(form.iso_code) }}
|
{{ form_row(form.iso_code) }}
|
||||||
{% if entity.isoCode %}
|
{% if entity.isoCode %}
|
||||||
<div class="mt-0 mb-3">
|
<div class="mt-0 mb-3">
|
||||||
<span class="form-text text-muted offset-3 col-9">
|
<span class="form-text text-muted {{ offset_label }} {{ col_input }}">
|
||||||
<b>{% trans %}currency.iso_code.caption{% endtrans %}:</b> {{ entity.isoCode }}
|
<b>{% trans %}currency.iso_code.caption{% endtrans %}:</b> {{ entity.isoCode }}
|
||||||
</span>
|
</span>
|
||||||
<span class="form-text text-muted offset-3 col-9">
|
<span class="form-text text-muted {{ offset_label }} {{ col_input }}">
|
||||||
<b>{% trans %}currency.symbol.caption{% endtrans %}:</b> {{ entity.isoCode | currency_symbol }}
|
<b>{% trans %}currency.symbol.caption{% endtrans %}:</b> {{ entity.isoCode | currency_symbol }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
{{ form_row(form.exchange_rate) }}
|
{{ form_row(form.exchange_rate) }}
|
||||||
{% if entity.inverseExchangeRate %}
|
{% if entity.inverseExchangeRate %}
|
||||||
<p class="form-text text-muted offset-3 col-9">
|
<p class="form-text text-muted {{ offset_label }} {{ col_input }}">
|
||||||
{{ '1'|format_currency(vars.base_currency()) }} = {{ entity.inverseExchangeRate.tofloat | format_currency(entity.isoCode, {fraction_digit: 5}) }}<br>
|
{{ '1'|format_currency(vars.base_currency()) }} = {{ entity.inverseExchangeRate.tofloat | format_currency(entity.isoCode, {fraction_digit: 5}) }}<br>
|
||||||
{{ '1'|format_currency(entity.isoCode) }} = {{ entity.exchangeRate.tofloat | format_currency(vars.base_currency(), {fraction_digit: 5}) }}
|
{{ '1'|format_currency(entity.isoCode) }} = {{ entity.exchangeRate.tofloat | format_currency(vars.base_currency(), {fraction_digit: 5}) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
{% block additional_panes %}
|
{% block additional_panes %}
|
||||||
<div class="tab-pane" id="eda">
|
<div class="tab-pane" id="eda">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
<h6>{% trans %}eda_info.kicad_section.title{% endtrans %}:</h6>
|
<h6>{% trans %}eda_info.kicad_section.title{% endtrans %}:</h6>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
{{ form_row(form.options.supported_element) }}
|
{{ form_row(form.options.supported_element) }}
|
||||||
<div class="mb-2 row">
|
<div class="mb-2 row">
|
||||||
{{ form_label(form.options.width) }}
|
{{ form_label(form.options.width) }}
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
{{ form_widget(form.options.width) }}
|
{{ form_widget(form.options.width) }}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@
|
||||||
{{ form_row(form.status) }}
|
{{ form_row(form.status) }}
|
||||||
{% if entity.id %}
|
{% if entity.id %}
|
||||||
<div class="mb-2 row">
|
<div class="mb-2 row">
|
||||||
<label class="col-form-label col-sm-3">{% trans %}project.edit.associated_build_part{% endtrans %}</label>
|
<label class="col-form-label {{ col_label }}">{% trans %}project.edit.associated_build_part{% endtrans %}</label>
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
{% if entity.buildPart %}
|
{% if entity.buildPart %}
|
||||||
<span class="form-control-static"><a href="{{ entity_url(entity.buildPart) }}">{{ entity.buildPart.name }}</a></span>
|
<span class="form-control-static"><a href="{{ entity_url(entity.buildPart) }}">{{ entity.buildPart.name }}</a></span>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
{% block additional_controls %}
|
{% block additional_controls %}
|
||||||
{% if entity.id %}
|
{% if entity.id %}
|
||||||
<div class="row form-group">
|
<div class="row form-group">
|
||||||
<div class="offset-sm-3 col-sm-9">
|
<div class="{{ offset_label }} {{ col_input }}">
|
||||||
{{ dropdown.profile_dropdown('storelocation', entity.id) }}
|
{{ dropdown.profile_dropdown('storelocation', entity.id) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
{% extends "main_card.html.twig" %}
|
{% extends "main_card.html.twig" %}
|
||||||
|
|
||||||
|
{% import "helper.twig" as helper %}
|
||||||
|
|
||||||
{% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %}
|
{% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %}
|
||||||
|
|
||||||
{% block card_title %}
|
{% block card_title %}
|
||||||
|
|
@ -7,60 +9,60 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block card_content %}
|
{% block card_content %}
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
{# Maintenance Mode Warning #}
|
{# Maintenance Mode Warning #}
|
||||||
{% if is_maintenance %}
|
{% if is_maintenance %}
|
||||||
<div class="alert alert-warning" role="alert">
|
<div class="alert alert-warning" role="alert">
|
||||||
<i class="fas fa-tools me-2"></i>
|
<i class="fas fa-tools me-2"></i>
|
||||||
<strong>{% trans %}update_manager.maintenance_mode_active{% endtrans %}</strong>
|
<strong>{% trans %}update_manager.maintenance_mode_active{% endtrans %}</strong>
|
||||||
{% if maintenance_info.reason is defined %}
|
{% if maintenance_info.reason is defined %}
|
||||||
- {{ maintenance_info.reason }}
|
- {{ maintenance_info.reason }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Lock Warning #}
|
{# Lock Warning #}
|
||||||
{% if is_locked %}
|
{% if is_locked %}
|
||||||
<div class="alert alert-info" role="alert">
|
<div class="alert alert-info" role="alert">
|
||||||
<i class="fas fa-lock me-2"></i>
|
<i class="fas fa-lock me-2"></i>
|
||||||
<strong>{% trans %}update_manager.update_in_progress{% endtrans %}</strong>
|
<strong>{% trans %}update_manager.update_in_progress{% endtrans %}</strong>
|
||||||
{% if lock_info.started_at is defined %}
|
{% if lock_info.started_at is defined %}
|
||||||
({% trans %}update_manager.started_at{% endtrans %}: {{ lock_info.started_at }})
|
({% trans %}update_manager.started_at{% endtrans %}: {{ lock_info.started_at }})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ path('admin_update_manager_progress') }}" class="alert-link ms-2">
|
<a href="{{ path('admin_update_manager_progress') }}" class="alert-link ms-2">
|
||||||
{% trans %}update_manager.view_progress{% endtrans %}
|
{% trans %}update_manager.view_progress{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Web Updates Disabled Warning #}
|
{# Web Updates Disabled Warning #}
|
||||||
{% if web_updates_disabled %}
|
{% if web_updates_disabled %}
|
||||||
<div class="alert alert-secondary" role="alert">
|
<div class="alert alert-secondary" role="alert">
|
||||||
<i class="fas fa-ban me-2"></i>
|
<i class="fas fa-ban me-2"></i>
|
||||||
<strong>{% trans %}update_manager.web_updates_disabled{% endtrans %}</strong>
|
<strong>{% trans %}update_manager.web_updates_disabled{% endtrans %}</strong>
|
||||||
<p class="mb-0 mt-2">{% trans %}update_manager.web_updates_disabled_hint{% endtrans %}</p>
|
<p class="mb-0 mt-2">{% trans %}update_manager.web_updates_disabled_hint{% endtrans %}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Backup Restore Disabled Warning #}
|
{# Backup Restore Disabled Warning #}
|
||||||
{% if backup_restore_disabled %}
|
{% if backup_restore_disabled %}
|
||||||
<div class="alert alert-secondary" role="alert">
|
<div class="alert alert-secondary" role="alert">
|
||||||
<i class="fas fa-ban me-2"></i>
|
<i class="fas fa-ban me-2"></i>
|
||||||
<strong>{% trans %}update_manager.backup_restore_disabled{% endtrans %}</strong>
|
<strong>{% trans %}update_manager.backup_restore_disabled{% endtrans %}</strong>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{# Current Version Card #}
|
{# Current Version Card #}
|
||||||
<div class="col-lg-6 mb-4">
|
<div class="col-lg-6 mb-4">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i class="fas fa-info-circle me-2"></i>{% trans %}update_manager.current_installation{% endtrans %}
|
<i class="fas fa-info-circle me-2"></i>{% trans %}update_manager.current_installation{% endtrans %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row" style="width: 40%">{% trans %}update_manager.version{% endtrans %}</th>
|
<th scope="row" style="width: 40%">{% trans %}update_manager.version{% endtrans %}</th>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -100,153 +102,159 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
|
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
|
||||||
<td>
|
<td>
|
||||||
{% if status.can_auto_update %}
|
{{ helper.boolean_badge(status.can_auto_update) }}
|
||||||
<span class="badge bg-success">
|
|
||||||
<i class="fas fa-check me-1"></i>{% trans %}Yes{% endtrans %}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary">
|
|
||||||
<i class="fas fa-times me-1"></i>{% trans %}No{% endtrans %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
|
||||||
</table>
|
<tr>
|
||||||
</div>
|
<th scope="row">{% trans %}update_manager.web_updates_allowed{% endtrans %}</th>
|
||||||
<div class="card-footer">
|
<td>{{ helper.boolean_badge(not web_updates_disabled) }}</td>
|
||||||
<form action="{{ path('admin_update_manager_refresh') }}" method="post" class="d-inline">
|
</tr>
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_refresh') }}">
|
<tr>
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
<th scope="row">{% trans %}update_manager.backup_restore_allowed{% endtrans %}</th>
|
||||||
<i class="fas fa-sync-alt me-1"></i> {% trans %}update_manager.refresh{% endtrans %}
|
<td>{{ helper.boolean_badge(not backup_restore_disabled) }}</td>
|
||||||
</button>
|
</tr>
|
||||||
</form>
|
<tr>
|
||||||
|
<th scope="row">{% trans %}update_manager.backup_download_allowed{% endtrans %}</th>
|
||||||
|
<td>{{ helper.boolean_badge(not backup_download_disabled) }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<form action="{{ path('admin_update_manager_refresh') }}" method="post" class="d-inline">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_refresh') }}">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="fas fa-sync-alt me-1"></i> {% trans %}update_manager.refresh{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Latest Version / Update Card #}
|
{# Latest Version / Update Card #}
|
||||||
<div class="col-lg-6 mb-4">
|
<div class="col-lg-6 mb-4">
|
||||||
<div class="card h-100 {{ status.update_available ? 'border-success' : '' }}">
|
<div class="card h-100 {{ status.update_available ? 'border-success' : '' }}">
|
||||||
<div class="card-header {{ status.update_available ? 'bg-success text-white' : '' }}">
|
<div class="card-header {{ status.update_available ? 'bg-success text-white' : '' }}">
|
||||||
{% if status.update_available %}
|
{% if status.update_available %}
|
||||||
<i class="fas fa-gift me-2"></i>{% trans %}update_manager.new_version_available.title{% endtrans %}
|
<i class="fas fa-gift me-2"></i>{% trans %}update_manager.new_version_available.title{% endtrans %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="fas fa-check-circle me-2"></i>{% trans %}update_manager.latest_release{% endtrans %}
|
<i class="fas fa-check-circle me-2"></i>{% trans %}update_manager.latest_release{% endtrans %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if status.latest_version %}
|
{% if status.latest_version %}
|
||||||
<div class="text-center mb-3">
|
<div class="text-center mb-3">
|
||||||
<span class="badge bg-{{ status.update_available ? 'success' : 'primary' }} fs-4 px-4 py-2">
|
<span class="badge bg-{{ status.update_available ? 'success' : 'primary' }} fs-4 px-4 py-2">
|
||||||
{{ status.latest_tag }}
|
{{ status.latest_tag }}
|
||||||
</span>
|
</span>
|
||||||
{% if not status.update_available %}
|
{% if not status.update_available %}
|
||||||
<p class="text-success mt-2 mb-0">
|
<p class="text-success mt-2 mb-0">
|
||||||
<i class="fas fa-check-circle me-1"></i>
|
<i class="fas fa-check-circle me-1"></i>
|
||||||
{% trans %}update_manager.already_up_to_date{% endtrans %}
|
{% trans %}update_manager.already_up_to_date{% endtrans %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %}
|
||||||
|
<form action="{{ path('admin_update_manager_start') }}" method="post"
|
||||||
|
data-controller="update-confirm"
|
||||||
|
data-update-confirm-is-downgrade-value="false"
|
||||||
|
data-update-confirm-target-version-value="{{ status.latest_tag }}"
|
||||||
|
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
|
||||||
|
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
|
||||||
|
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
|
||||||
|
<input type="hidden" name="version" value="{{ status.latest_tag }}">
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-success btn-lg">
|
||||||
|
<i class="fas fa-download me-2"></i>
|
||||||
|
{% trans %}update_manager.update_to{% endtrans %} {{ status.latest_tag }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup" checked>
|
||||||
|
<label class="form-check-label" for="create-backup">
|
||||||
|
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if status.published_at %}
|
||||||
|
<p class="text-muted small mt-3 mb-0">
|
||||||
|
<i class="fas fa-calendar me-1"></i>
|
||||||
|
{% trans %}update_manager.released{% endtrans %}: {{ status.published_at|date('Y-m-d') }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
{% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %}
|
<i class="fas fa-question-circle fa-3x mb-3"></i>
|
||||||
<form action="{{ path('admin_update_manager_start') }}" method="post"
|
<p>{% trans %}update_manager.could_not_fetch_releases{% endtrans %}</p>
|
||||||
data-controller="update-confirm"
|
</div>
|
||||||
data-update-confirm-is-downgrade-value="false"
|
|
||||||
data-update-confirm-target-version-value="{{ status.latest_tag }}"
|
|
||||||
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
|
|
||||||
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
|
|
||||||
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
|
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
|
|
||||||
<input type="hidden" name="version" value="{{ status.latest_tag }}">
|
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
<button type="submit" class="btn btn-success btn-lg">
|
|
||||||
<i class="fas fa-download me-2"></i>
|
|
||||||
{% trans %}update_manager.update_to{% endtrans %} {{ status.latest_tag }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check mt-3">
|
|
||||||
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup" checked>
|
|
||||||
<label class="form-check-label" for="create-backup">
|
|
||||||
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% if status.published_at %}
|
{% if status.latest_tag %}
|
||||||
<p class="text-muted small mt-3 mb-0">
|
<div class="card-footer">
|
||||||
<i class="fas fa-calendar me-1"></i>
|
<a href="{{ path('admin_update_manager_release', {tag: status.latest_tag}) }}" class="btn btn-outline-primary btn-sm">
|
||||||
{% trans %}update_manager.released{% endtrans %}: {{ status.published_at|date('Y-m-d') }}
|
<i class="fas fa-file-alt me-1"></i> {% trans %}update_manager.view_release_notes{% endtrans %}
|
||||||
</p>
|
</a>
|
||||||
{% endif %}
|
{% if status.release_url %}
|
||||||
{% else %}
|
<a href="{{ status.release_url }}" class="btn btn-outline-secondary btn-sm" target="_blank">
|
||||||
<div class="text-center text-muted py-4">
|
<i class="fab fa-github me-1"></i> GitHub
|
||||||
<i class="fas fa-question-circle fa-3x mb-3"></i>
|
</a>
|
||||||
<p>{% trans %}update_manager.could_not_fetch_releases{% endtrans %}</p>
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if status.latest_tag %}
|
|
||||||
<div class="card-footer">
|
|
||||||
<a href="{{ path('admin_update_manager_release', {tag: status.latest_tag}) }}" class="btn btn-outline-primary btn-sm">
|
|
||||||
<i class="fas fa-file-alt me-1"></i> {% trans %}update_manager.view_release_notes{% endtrans %}
|
|
||||||
</a>
|
|
||||||
{% if status.release_url %}
|
|
||||||
<a href="{{ status.release_url }}" class="btn btn-outline-secondary btn-sm" target="_blank">
|
|
||||||
<i class="fab fa-github me-1"></i> GitHub
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Validation Issues #}
|
{# Validation Issues #}
|
||||||
{% if not validation.valid %}
|
{% if not validation.valid %}
|
||||||
<div class="alert alert-warning" role="alert">
|
<div class="alert alert-warning" role="alert">
|
||||||
<h6 class="alert-heading">
|
<h6 class="alert-heading">
|
||||||
<i class="fas fa-exclamation-triangle me-2"></i>{% trans %}update_manager.validation_issues{% endtrans %}
|
<i class="fas fa-exclamation-triangle me-2"></i>{% trans %}update_manager.validation_issues{% endtrans %}
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="mb-0">
|
<ul class="mb-0">
|
||||||
{% for error in validation.errors %}
|
{% for error in validation.errors %}
|
||||||
<li>{{ error }}</li>
|
<li>{{ error }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Non-auto-update installations info #}
|
{# Non-auto-update installations info #}
|
||||||
{% if not status.can_auto_update %}
|
{% if not status.can_auto_update %}
|
||||||
<div class="alert alert-secondary">
|
<div class="alert alert-secondary">
|
||||||
<h6 class="alert-heading">
|
<h6 class="alert-heading">
|
||||||
<i class="fas fa-info-circle me-2"></i>{% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
|
<i class="fas fa-info-circle me-2"></i>{% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
|
||||||
</h6>
|
</h6>
|
||||||
<p class="mb-0">{{ status.installation.update_instructions }}</p>
|
<p class="mb-0">{{ status.installation.update_instructions }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{# Available Versions #}
|
{# Available Versions #}
|
||||||
<div class="col-lg-6 mb-4">
|
<div class="col-lg-6 mb-4">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i class="fas fa-tags me-2"></i>{% trans %}update_manager.available_versions{% endtrans %}
|
<i class="fas fa-tags me-2"></i>{% trans %}update_manager.available_versions{% endtrans %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||||
<table class="table table-hover table-sm mb-0">
|
<table class="table table-hover table-sm mb-0">
|
||||||
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% trans %}update_manager.version{% endtrans %}</th>
|
<th>{% trans %}update_manager.version{% endtrans %}</th>
|
||||||
<th>{% trans %}update_manager.released{% endtrans %}</th>
|
<th>{% trans %}update_manager.released{% endtrans %}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for release in all_releases %}
|
{% for release in all_releases %}
|
||||||
<tr{% if release.version == status.current_version %} class="table-active"{% endif %}>
|
<tr{% if release.version == status.current_version %} class="table-active"{% endif %}>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -280,8 +288,8 @@
|
||||||
<input type="hidden" name="version" value="{{ release.tag }}">
|
<input type="hidden" name="version" value="{{ release.tag }}">
|
||||||
<input type="hidden" name="backup" value="1">
|
<input type="hidden" name="backup" value="1">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="btn btn-{{ release.version > status.current_version ? 'outline-success' : 'outline-warning' }}"
|
class="btn btn-{{ release.version > status.current_version ? 'outline-success' : 'outline-warning' }}"
|
||||||
title="{% trans %}update_manager.switch_to{% endtrans %} {{ release.tag }}">
|
title="{% trans %}update_manager.switch_to{% endtrans %} {{ release.tag }}">
|
||||||
{% if release.version > status.current_version %}
|
{% if release.version > status.current_version %}
|
||||||
<i class="fas fa-arrow-up"></i>
|
<i class="fas fa-arrow-up"></i>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -300,54 +308,69 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Update History & Backups #}
|
{# Update History & Backups #}
|
||||||
<div class="col-lg-6 mb-4">
|
<div class="col-lg-6 mb-4">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#logs-tab" type="button">
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#logs-tab" type="button">
|
||||||
<i class="fas fa-history me-1"></i>{% trans %}update_manager.update_logs{% endtrans %}
|
<i class="fas fa-history me-1"></i>{% trans %}update_manager.update_logs{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#backups-tab" type="button">
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#backups-tab" type="button">
|
||||||
<i class="fas fa-archive me-1"></i>{% trans %}update_manager.backups{% endtrans %}
|
<i class="fas fa-archive me-1"></i>{% trans %}update_manager.backups{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div class="tab-pane fade show active" id="logs-tab">
|
<div class="tab-pane fade show active" id="logs-tab">
|
||||||
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
|
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
|
||||||
<table class="table table-hover table-sm mb-0">
|
<table class="table table-hover table-sm mb-0">
|
||||||
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% trans %}update_manager.date{% endtrans %}</th>
|
<th>{% trans %}update_manager.date{% endtrans %}</th>
|
||||||
<th>{% trans %}update_manager.log_file{% endtrans %}</th>
|
<th>{% trans %}update_manager.log_file{% endtrans %}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for log in update_logs %}
|
{% for log in update_logs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-muted small">
|
<td class="text-muted small">
|
||||||
{{ log.date|date('Y-m-d H:i') }}
|
{{ log.date|date('Y-m-d H:i') }}
|
||||||
</td>
|
</td>
|
||||||
<td><code class="small">{{ log.file }}</code></td>
|
<td><code class="small">{{ log.file }}</code></td>
|
||||||
<td>
|
<td class="text-end">
|
||||||
<a href="{{ path('admin_update_manager_log', {filename: log.file}) }}"
|
<div class="btn-group btn-group-sm">
|
||||||
class="btn btn-sm btn-outline-secondary">
|
<a href="{{ path('admin_update_manager_log', {filename: log.file}) }}"
|
||||||
<i class="fas fa-eye"></i>
|
class="btn btn-outline-secondary"
|
||||||
</a>
|
title="{% trans %}update_manager.view_log{% endtrans %}">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if is_granted('@system.manage_updates') %}
|
||||||
|
<form action="{{ path('admin_update_manager_log_delete') }}" method="post" class="d-inline"
|
||||||
|
data-turbo-confirm="{% trans %}update_manager.log.delete.confirm{% endtrans %}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_delete') }}">
|
||||||
|
<input type="hidden" name="filename" value="{{ log.file }}">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-outline-danger"
|
||||||
|
title="{% trans %}update_manager.delete{% endtrans %}">
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -357,22 +380,39 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="tab-pane fade" id="backups-tab">
|
||||||
<div class="tab-pane fade" id="backups-tab">
|
{% if is_granted('@system.manage_updates') and not is_locked %}
|
||||||
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
|
<div class="p-2 border-bottom">
|
||||||
<table class="table table-hover table-sm mb-0">
|
<form action="{{ path('admin_update_manager_backup') }}" method="post" class="d-inline"
|
||||||
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
data-turbo-confirm="{% trans %}update_manager.backup.create.confirm{% endtrans %}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_backup') }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-success">
|
||||||
|
<i class="fas fa-plus me-1"></i>{% trans %}update_manager.backup.create{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_docker %}
|
||||||
|
<div class="alert alert-info alert-sm m-2 mb-0 py-2 small">
|
||||||
|
<i class="fas fa-info-circle me-1"></i>
|
||||||
|
{% trans %}update_manager.backup.docker_warning{% endtrans %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
|
||||||
|
<table class="table table-hover table-sm mb-0">
|
||||||
|
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% trans %}update_manager.date{% endtrans %}</th>
|
<th>{% trans %}update_manager.date{% endtrans %}</th>
|
||||||
<th>{% trans %}update_manager.file{% endtrans %}</th>
|
<th>{% trans %}update_manager.file{% endtrans %}</th>
|
||||||
<th>{% trans %}update_manager.size{% endtrans %}</th>
|
<th>{% trans %}update_manager.size{% endtrans %}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for backup in backups %}
|
{% for backup in backups %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-muted small">
|
<td class="text-muted small">
|
||||||
|
|
@ -383,23 +423,89 @@
|
||||||
{{ (backup.size / 1024 / 1024)|number_format(1) }} MB
|
{{ (backup.size / 1024 / 1024)|number_format(1) }} MB
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
{% if status.can_auto_update and validation.valid and not backup_restore_disabled %}
|
<div class="btn-group btn-group-sm">
|
||||||
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
|
{% if not backup_download_disabled and is_granted('@system.manage_updates') %}
|
||||||
data-controller="backup-restore"
|
<button type="button"
|
||||||
data-backup-restore-filename-value="{{ backup.file }}"
|
class="btn btn-outline-secondary"
|
||||||
data-backup-restore-date-value="{{ backup.date|date('Y-m-d H:i') }}"
|
data-bs-toggle="modal"
|
||||||
data-backup-restore-confirm-title-value="{{ 'update_manager.restore_confirm_title'|trans }}"
|
data-bs-target="#downloadBackupModal-{{ loop.index }}"
|
||||||
data-backup-restore-confirm-message-value="{{ 'update_manager.restore_confirm_message'|trans }}"
|
title="{% trans %}update_manager.backup.download{% endtrans %}">
|
||||||
data-backup-restore-confirm-warning-value="{{ 'update_manager.restore_confirm_warning'|trans }}">
|
<i class="fas fa-download"></i>
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_restore') }}">
|
|
||||||
<input type="hidden" name="filename" value="{{ backup.file }}">
|
|
||||||
<input type="hidden" name="restore_database" value="1">
|
|
||||||
<button type="submit"
|
|
||||||
class="btn btn-sm btn-outline-warning"
|
|
||||||
title="{% trans %}update_manager.restore_backup{% endtrans %}">
|
|
||||||
<i class="fas fa-undo"></i>
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
{% endif %}
|
||||||
|
{% if not backup_restore_disabled and is_granted('@system.manage_updates') %}
|
||||||
|
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
|
||||||
|
{{ stimulus_controller('backup-restore') }}
|
||||||
|
data-backup-restore-filename-value="{{ backup.file }}"
|
||||||
|
data-backup-restore-date-value="{{ backup.date|date('Y-m-d H:i') }}"
|
||||||
|
data-backup-restore-confirm-title-value="{{ 'update_manager.restore_confirm_title'|trans }}"
|
||||||
|
data-backup-restore-confirm-message-value="{{ 'update_manager.restore_confirm_message'|trans }}"
|
||||||
|
data-backup-restore-confirm-warning-value="{{ 'update_manager.restore_confirm_warning'|trans }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_restore') }}">
|
||||||
|
<input type="hidden" name="filename" value="{{ backup.file }}">
|
||||||
|
<input type="hidden" name="restore_database" value="1">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-outline-warning"
|
||||||
|
title="{% trans %}update_manager.restore_backup{% endtrans %}">
|
||||||
|
<i class="fas fa-undo"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_granted('@system.manage_updates') %}
|
||||||
|
<form action="{{ path('admin_update_manager_backup_delete') }}" method="post" class="d-inline"
|
||||||
|
data-turbo-confirm="{% trans %}update_manager.backup.delete.confirm{% endtrans %}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_delete') }}">
|
||||||
|
<input type="hidden" name="filename" value="{{ backup.file }}">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-outline-danger"
|
||||||
|
title="{% trans %}update_manager.delete{% endtrans %}">
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not backup_download_disabled and is_granted('@system.manage_updates') %}
|
||||||
|
{# Per-backup download modal - no inline JS needed, CSP compatible with Turbo #}
|
||||||
|
<div class="modal fade text-start" id="downloadBackupModal-{{ loop.index }}" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form action="{{ path('admin_update_manager_backup_download') }}" method="post" data-turbo="false">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-download me-2"></i>{% trans %}update_manager.backup.download{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="fas fa-exclamation-triangle me-1"></i>
|
||||||
|
{% trans %}update_manager.backup.download.security_warning{% endtrans %}
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-3">{{ backup.file }}</p>
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_download') }}">
|
||||||
|
<input type="hidden" name="filename" value="{{ backup.file }}">
|
||||||
|
<input type="hidden" name="username" autocomplete="username">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="downloadPassword-{{ loop.index }}" class="form-label">
|
||||||
|
{% trans %}update_manager.backup.download.password_label{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<input type="password" class="form-control" id="downloadPassword-{{ loop.index }}"
|
||||||
|
name="password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||||
|
{% trans %}modal.cancel{% endtrans %}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" data-bs-dismiss="modal">
|
||||||
|
<i class="fas fa-download me-1"></i>{% trans %}update_manager.backup.download{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -410,8 +516,9 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -419,5 +526,5 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
|
|
||||||
<div class="tab-pane" id="password">
|
<div class="tab-pane" id="password">
|
||||||
{% if entity.samlUser %}
|
{% if entity.samlUser %}
|
||||||
<div class="offset-3 mb-3 col-9">
|
<div class="mb-3 {{ offset_label }} {{ col_input }}">
|
||||||
<span class="badge badge-warning bg-warning"><i class="fa-solid fa-house-user"></i> {% trans %}user.saml_user{% endtrans %}</span>
|
<span class="badge badge-warning bg-warning"><i class="fa-solid fa-house-user"></i> {% trans %}user.saml_user{% endtrans %}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
{{ form_row(form.disabled) }}
|
{{ form_row(form.disabled) }}
|
||||||
|
|
||||||
{% if entity.id is not null %}
|
{% if entity.id is not null %}
|
||||||
<div class="offset-3 mb-3">
|
<div class="{{ offset_label }} {{ col_input }} mb-3">
|
||||||
<hr>
|
<hr>
|
||||||
<h6>{% trans %}user.edit.tfa.caption{% endtrans %}</h6>
|
<h6>{% trans %}user.edit.tfa.caption{% endtrans %}</h6>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
{{ form_row(filterForm.discard) }}
|
{{ form_row(filterForm.discard) }}
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
<button type="button" class="btn btn-danger" {{ stimulus_action('helpers/form_cleanup', 'clearAll') }}>{% trans %}filter.clear_filters{% endtrans %}</button>
|
<button type="button" class="btn btn-danger" {{ stimulus_action('helpers/form_cleanup', 'clearAll') }}>{% trans %}filter.clear_filters{% endtrans %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
<b><i>Can not load frontend assets.</i></b><p>Try following things:</p>
|
<b><i>Can not load frontend assets.</i></b><p>Try following things:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Run <kbd>yarn install</kbd> and <kbd>yarn build</kbd> in Part-DB folder.</li>
|
<li>Run <kbd>yarn install</kbd> and <kbd>yarn build</kbd> in Part-DB folder.</li>
|
||||||
<li>Run <kbd>php bin/console cache:clear</kbd></li>
|
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% elseif exception.class == "Doctrine\\DBAL\\Exception\\InvalidFieldNameException"
|
{% elseif exception.class == "Doctrine\\DBAL\\Exception\\InvalidFieldNameException"
|
||||||
or exception.class == "Doctrine\\DBAL\\Exception\\TableNotFoundException"
|
or exception.class == "Doctrine\\DBAL\\Exception\\TableNotFoundException"
|
||||||
|
|
@ -26,21 +26,21 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li>Check if the <code>DATABASE_URL</code> in <code>.env.local</code> (or docker configure) is correct</li>
|
<li>Check if the <code>DATABASE_URL</code> in <code>.env.local</code> (or docker configure) is correct</li>
|
||||||
<li>Run <kbd>php bin/console doctrine:migrations:migrate</kbd> to upgrade database schema</li>
|
<li>Run <kbd>php bin/console doctrine:migrations:migrate</kbd> to upgrade database schema</li>
|
||||||
<li>Run <kbd>php bin/console cache:clear</kbd></li>
|
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% elseif exception.class == "Doctrine\\DBAL\\Exception\\DriverException" %}
|
{% elseif exception.class == "Doctrine\\DBAL\\Exception\\DriverException" %}
|
||||||
<b><i>Error while executing database query.</i></b><br>This is maybe caused by an old database schema.<br><p>Try following things:</p>
|
<b><i>Error while executing database query.</i></b><br>This is maybe caused by an old database schema.<br><p>Try following things:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Check if the <code>DATABASE_URL</code> in <code>.env.local</code> (or docker configure) is correct</li>
|
<li>Check if the <code>DATABASE_URL</code> in <code>.env.local</code> (or docker configure) is correct</li>
|
||||||
<li>Run <kbd>php bin/console doctrine:migrations:migrate</kbd> to upgrade database schema (if upgrade is available)</li>
|
<li>Run <kbd>php bin/console doctrine:migrations:migrate</kbd> to upgrade database schema (if upgrade is available)</li>
|
||||||
<li>Run <kbd>php bin/console cache:clear</kbd></li>
|
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd></li>
|
||||||
<li>If this issue persist, create a ticket at <a href="https://github.com/Part-DB/Part-DB-symfony/issues" rel="noopener">GitHub</a>.</li>
|
<li>If this issue persist, create a ticket at <a href="https://github.com/Part-DB/Part-DB-symfony/issues" rel="noopener">GitHub</a>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
You could try following things, if this error is unexpected:
|
You could try following things, if this error is unexpected:
|
||||||
<ul>
|
<ul>
|
||||||
<li>Check <code>var/log/prod.log</code> (or <code>docker logs</code> when Part-DB is running inside a docker container) for additional informations</li>
|
<li>Check <code>var/log/prod.log</code> (or <code>docker logs</code> when Part-DB is running inside a docker container) for additional informations</li>
|
||||||
<li>Run <kbd>php bin/console cache:clear</kbd> to clear cache</li>
|
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd> to clear caches</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -63,8 +63,8 @@
|
||||||
<div class="">
|
<div class="">
|
||||||
{{ form_row(form.mountnames) }}
|
{{ form_row(form.mountnames) }}
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<label class="col-form-label col-sm-3">{% trans %}project.bom.price{% endtrans %}</label>
|
<label class="col-form-label {{ col_label }}">{% trans %}project.bom.price{% endtrans %}</label>
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
{{ form_widget(form.price) }}
|
{{ form_widget(form.price) }}
|
||||||
{{ form_widget(form.priceCurrency) }}
|
{{ form_widget(form.priceCurrency) }}
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,11 @@
|
||||||
|
|
||||||
|
|
||||||
{% block form_label_class -%}
|
{% block form_label_class -%}
|
||||||
col-sm-3
|
{{ col_label }}
|
||||||
{%- endblock form_label_class %}
|
{%- endblock form_label_class %}
|
||||||
|
|
||||||
{% block form_group_class -%}
|
{% block form_group_class -%}
|
||||||
col-sm-9
|
{{ col_input }}
|
||||||
{%- endblock form_group_class %}
|
{%- endblock form_group_class %}
|
||||||
|
|
||||||
{% block si_unit_widget %}
|
{% block si_unit_widget %}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{% macro boolean(value) %}
|
{% macro boolean(value) %}
|
||||||
{% if value %}
|
{% if value %}
|
||||||
{% trans %}bool.true{% endtrans %}
|
{% trans %}Yes{% endtrans %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans %}bool.false{% endtrans %}
|
{% trans %}No{% endtrans %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
@ -14,9 +14,9 @@
|
||||||
|
|
||||||
{% macro bool_icon(bool) %}
|
{% macro bool_icon(bool) %}
|
||||||
{% if bool %}
|
{% if bool %}
|
||||||
<i class="fas fa-check-circle fa-fw" title="{% trans %}Yes{% endtrans %}"></i>
|
<i class="fas fa-check fa-fw" title="{% trans %}Yes{% endtrans %}"></i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="fas fa-times-circle fa-fw" title="{% trans %}No{% endtrans %}"></i>
|
<i class="fas fa-times fa-fw" title="{% trans %}No{% endtrans %}"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
{% if value %}
|
{% if value %}
|
||||||
{% set class = class ~ ' bg-success' %}
|
{% set class = class ~ ' bg-success' %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set class = class ~ ' bg-danger' %}
|
{% set class = class ~ ' bg-secondary' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<span class="{{ class }}">{{ _self.bool_icon(value) }} {{ _self.boolean(value) }}</span>
|
<span class="{{ class }}">{{ _self.bool_icon(value) }} {{ _self.boolean(value) }}</span>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block card_content %}
|
{% block card_content %}
|
||||||
<p class="text-muted offset-3">{% trans %}info_providers.from_url.help{% endtrans %}</p>
|
<p class="text-muted {{ offset_label }}">{% trans %}info_providers.from_url.help{% endtrans %}</p>
|
||||||
|
|
||||||
{{ form_start(form) }}
|
{{ form_start(form) }}
|
||||||
{{ form_row(form.url) }}
|
{{ form_row(form.url) }}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
{{ form_row(form.providers) }}
|
{{ form_row(form.providers) }}
|
||||||
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
<a href="{{ path('info_providers_list') }}">{% trans %}info_providers.search.info_providers_list{% endtrans %}</a>
|
<a href="{{ path('info_providers_list') }}">{% trans %}info_providers.search.info_providers_list{% endtrans %}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
{% block card_title %}<i class="fa-solid fa-gear fa-fw"></i> {% trans %}info_providers.settings.title{% endtrans %}: <b>{{ info_provider_info.name }}</b>{% endblock %}
|
{% block card_title %}<i class="fa-solid fa-gear fa-fw"></i> {% trans %}info_providers.settings.title{% endtrans %}: <b>{{ info_provider_info.name }}</b>{% endblock %}
|
||||||
|
|
||||||
{% block card_content %}
|
{% block card_content %}
|
||||||
<div class="offset-sm-3">
|
<div class="{{ offset_label }}">
|
||||||
<h3>
|
<h3>
|
||||||
{% if info_provider_info.url is defined %}
|
{% if info_provider_info.url is defined %}
|
||||||
<a href="{{ info_provider_info.url }}" class="link-external" target="_blank" rel="nofollow">{{ info_provider_info.name }}</a>
|
<a href="{{ info_provider_info.url }}" class="link-external" target="_blank" rel="nofollow">{{ info_provider_info.name }}</a>
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
|
|
||||||
{{ form_start(form) }}
|
{{ form_start(form) }}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="offset-sm-3 col mb-3 ps-2">
|
<div class="{{ offset_label }} col mb-3 ps-2">
|
||||||
<b>{{ form_help(form) }}</b>
|
<b>{{ form_help(form) }}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
{{ form_row(form.options.supported_element) }}
|
{{ form_row(form.options.supported_element) }}
|
||||||
<div class="mb-2 row">
|
<div class="mb-2 row">
|
||||||
{{ form_label(form.options.width) }}
|
{{ form_label(form.options.width) }}
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
{{ form_widget(form.options.width) }}
|
{{ form_widget(form.options.width) }}
|
||||||
|
|
||||||
|
|
@ -59,8 +59,8 @@
|
||||||
|
|
||||||
<div class="tab-pane" id="profiles" role="tabpanel" aria-labelledby="profiles-tab">
|
<div class="tab-pane" id="profiles" role="tabpanel" aria-labelledby="profiles-tab">
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<label class="col-sm-3 col-form-label">{% trans %}label_generator.selected_profile{% endtrans %}</label>
|
<label class="{{ col_label }} col-form-label">{% trans %}label_generator.selected_profile{% endtrans %}</label>
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
<span class="form-control-plaintext">{{ profile.name ?? '-' }}
|
<span class="form-control-plaintext">{{ profile.name ?? '-' }}
|
||||||
{% if profile and is_granted("edit", profile) %}
|
{% if profile and is_granted("edit", profile) %}
|
||||||
<a href="{{ entity_url(profile, 'edit') }}" title="{% trans %}label_generator.edit_profile{% endtrans %}"
|
<a href="{{ entity_url(profile, 'edit') }}" title="{% trans %}label_generator.edit_profile{% endtrans %}"
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<div class="offset-sm-3 col-sm-9">
|
<div class="{{ offset_label }} {{ col_input }}">
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-info dropdown-toggle" type="button" id="loadProfilesButton"
|
<button class="btn btn-info dropdown-toggle" type="button" id="loadProfilesButton"
|
||||||
{% if not is_granted("@labels.create_labels") %}disabled{% endif %}
|
{% if not is_granted("@labels.create_labels") %}disabled{% endif %}
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
|
|
||||||
{% if is_granted("@labels.read_profiles") %}
|
{% if is_granted("@labels.read_profiles") %}
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<div class="offset-sm-3 col-sm-9">
|
<div class="{{ offset_label }} {{ col_input }}">
|
||||||
<a class="btn btn-link" href="{{ path('label_profile_new') }}">{% trans %}label_generator.edit_profiles{% endtrans %}</a>
|
<a class="btn btn-link" href="{{ path('label_profile_new') }}">{% trans %}label_generator.edit_profiles{% endtrans %}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -108,7 +108,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<div class="offset-sm-3 col-sm-9">
|
<div class="{{ offset_label }} {{ col_input }}">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
{{ form_widget(form.save_profile_name) }}
|
{{ form_widget(form.save_profile_name) }}
|
||||||
{{ form_widget(form.save_profile) }}
|
{{ form_widget(form.save_profile) }}
|
||||||
|
|
@ -124,7 +124,7 @@
|
||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
{% if pdf_data %}
|
{% if pdf_data %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
<a data-turbo="false" class="btn btn-secondary" href="#" {{ stimulus_controller('pages/label_download_btn')}} {{ stimulus_action('pages/label_download_btn', 'download')}} download="{{ filename ?? '' }}">
|
<a data-turbo="false" class="btn btn-secondary" href="#" {{ stimulus_controller('pages/label_download_btn')}} {{ stimulus_action('pages/label_download_btn', 'download')}} download="{{ filename ?? '' }}">
|
||||||
{% trans %}label_generator.download{% endtrans %}
|
{% trans %}label_generator.download{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<div class="offset-sm-3 col-sm-9">
|
<div class="{{ offset_label }} {{ col_input }}">
|
||||||
<div class="img-thumbnail" style="max-width: 600px;">
|
<div class="img-thumbnail" style="max-width: 600px;">
|
||||||
<div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}></div>
|
<div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
{{ form_row(filterForm.discard) }}
|
{{ form_row(filterForm.discard) }}
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
<button type="button" class="btn btn-danger" {{ stimulus_action('helpers/form_cleanup', 'clearAll') }}>{% trans %}filter.clear_filters{% endtrans %}</button>
|
<button type="button" class="btn btn-danger" {{ stimulus_action('helpers/form_cleanup', 'clearAll') }}>{% trans %}filter.clear_filters{% endtrans %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
{{ form_row(form.eda_info.visibility) }}
|
{{ form_row(form.eda_info.visibility) }}
|
||||||
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
{{ form_widget(form.eda_info.exclude_from_bom) }}
|
{{ form_widget(form.eda_info.exclude_from_bom) }}
|
||||||
{{ form_widget(form.eda_info.exclude_from_board) }}
|
{{ form_widget(form.eda_info.exclude_from_board) }}
|
||||||
{{ form_widget(form.eda_info.exclude_from_sim) }}
|
{{ form_widget(form.eda_info.exclude_from_sim) }}
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
<h6>{% trans %}eda_info.kicad_section.title{% endtrans %}:</h6>
|
<h6>{% trans %}eda_info.kicad_section.title{% endtrans %}:</h6>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{{ form_row(form.name) }}
|
{{ form_row(form.name) }}
|
||||||
{% if part.category is not null and part.category.partnameHint is not empty %}
|
{% if part.category is not null and part.category.partnameHint is not empty %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
<p class="form-text help-text"><b>{% trans %}part.edit.name.category_hint{% endtrans %}:</b> {{ part.category.partnameHint }}</p>
|
<p class="form-text help-text"><b>{% trans %}part.edit.name.category_hint{% endtrans %}:</b> {{ part.category.partnameHint }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@
|
||||||
|
|
||||||
{% set id = 'collapse_' ~ random() %}
|
{% set id = 'collapse_' ~ random() %}
|
||||||
|
|
||||||
<a class="btn btn-link offset-sm-3 btn-sm" data-bs-toggle="collapse" href="#{{ id }}" role="button" aria-expanded="false" aria-controls="{{ id }}">
|
<a class="btn btn-link {{ offset_label }} btn-sm" data-bs-toggle="collapse" href="#{{ id }}" role="button" aria-expanded="false" aria-controls="{{ id }}">
|
||||||
{% trans %}part_lot.edit.advanced{% endtrans %}
|
{% trans %}part_lot.edit.advanced{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
<div class="collapse" id="{{ id }}">
|
<div class="collapse" id="{{ id }}">
|
||||||
|
|
@ -142,7 +142,7 @@
|
||||||
|
|
||||||
<div class="mb-2 row">
|
<div class="mb-2 row">
|
||||||
{{ form_label(form.file) }}
|
{{ form_label(form.file) }}
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
{{ form_widget(form.file) }}
|
{{ form_widget(form.file) }}
|
||||||
{{ form_errors(form.file) }}
|
{{ form_errors(form.file) }}
|
||||||
<small class="text-muted">{% trans %}attachment.max_file_size{% endtrans %}: {{ max_upload_size | format_bytes }}</small>
|
<small class="text-muted">{% trans %}attachment.max_file_size{% endtrans %}: {{ max_upload_size | format_bytes }}</small>
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
{{ form_widget(form.save) }}
|
{{ form_widget(form.save) }}
|
||||||
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block card_content %}
|
{% block card_content %}
|
||||||
<p class="text-muted offset-sm-3">
|
<p class="text-muted {{ offset_label }}">
|
||||||
{% trans %}parts.import.help{% endtrans %}<br>
|
{% trans %}parts.import.help{% endtrans %}<br>
|
||||||
{% trans with {'%link%': 'https://docs.part-db.de/usage/import_export.html'} %}parts.import.help_documentation{% endtrans %}
|
{% trans with {'%link%': 'https://docs.part-db.de/usage/import_export.html'} %}parts.import.help_documentation{% endtrans %}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -13,39 +13,39 @@
|
||||||
<input type="hidden" name="_redirect" value="{{ uri_without_host(app.request) }}">
|
<input type="hidden" name="_redirect" value="{{ uri_without_host(app.request) }}">
|
||||||
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<label class="col-form-label col-sm-3">
|
<label class="col-form-label {{ col_label }}">
|
||||||
{% trans %}part.info.stocktake_modal.expected_amount{% endtrans %}
|
{% trans %}part.info.stocktake_modal.expected_amount{% endtrans %}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
<span id="stocktake-modal-expected-amount" class="form-control-plaintext">0</span>
|
<span id="stocktake-modal-expected-amount" class="form-control-plaintext">0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<label class="col-form-label col-sm-3">
|
<label class="col-form-label {{ col_label }}">
|
||||||
{% trans %}part.info.stocktake_modal.actual_amount{% endtrans %}
|
{% trans %}part.info.stocktake_modal.actual_amount{% endtrans %}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
<input type="number" required class="form-control" min="0" step="{{ (part.partUnit and not part.partUnit.integer) ? 'any' : '1' }}" name="actual_amount" value="">
|
<input type="number" required class="form-control" min="0" step="{{ (part.partUnit and not part.partUnit.integer) ? 'any' : '1' }}" name="actual_amount" value="">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<label class="col-form-label col-sm-3">
|
<label class="col-form-label {{ col_label }}">
|
||||||
{% trans %}part.info.withdraw_modal.comment{% endtrans %}
|
{% trans %}part.info.withdraw_modal.comment{% endtrans %}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
<input type="text" class="form-control" name="comment" value="">
|
<input type="text" class="form-control" name="comment" value="">
|
||||||
<div class="form-text">{% trans %}part.info.withdraw_modal.comment.hint{% endtrans %}</div>
|
<div class="form-text">{% trans %}part.info.withdraw_modal.comment.hint{% endtrans %}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<label class="col-form-label col-sm-3">
|
<label class="col-form-label {{ col_label }}">
|
||||||
{% trans %}part.info.withdraw_modal.timestamp{% endtrans %}
|
{% trans %}part.info.withdraw_modal.timestamp{% endtrans %}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
{# The timestamp must be between a year ago and 1 hour in the future #}
|
{# The timestamp must be between a year ago and 1 hour in the future #}
|
||||||
<input type="datetime-local" class="form-control" name="timestamp" value=""
|
<input type="datetime-local" class="form-control" name="timestamp" value=""
|
||||||
max="{{ "+10mins"|date('Y-m-d\\TH:i') }}" min="{{ "-1year"|date('Y-m-d\\TH:i') }}">
|
max="{{ "+10mins"|date('Y-m-d\\TH:i') }}" min="{{ "-1year"|date('Y-m-d\\TH:i') }}">
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,17 @@
|
||||||
<input type="hidden" name="_redirect" value="{{ uri_without_host(app.request) }}">
|
<input type="hidden" name="_redirect" value="{{ uri_without_host(app.request) }}">
|
||||||
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<label class="col-form-label col-sm-3">
|
<label class="col-form-label {{ col_label }}">
|
||||||
{% trans %}part.info.withdraw_modal.amount{% endtrans %}
|
{% trans %}part.info.withdraw_modal.amount{% endtrans %}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
<input type="number" required class="form-control" min="0" step="{{ (part.partUnit and not part.partUnit.integer) ? 'any' : '1' }}" name="amount" value="">
|
<input type="number" required class="form-control" min="0" step="{{ (part.partUnit and not part.partUnit.integer) ? 'any' : '1' }}" name="amount" value="">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-2 d-none" id="withdraw-modal-move-to">
|
<div class="row mb-2 d-none" id="withdraw-modal-move-to">
|
||||||
<label class="col-form-label col-sm-3">{% trans %}part.info.withdraw_modal.move_to{% endtrans %}</label>
|
<label class="col-form-label {{ col_label }}">{% trans %}part.info.withdraw_modal.move_to{% endtrans %}</label>
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
{% for lots in part.partLots|filter(l => l.instockUnknown == false) %}
|
{% for lots in part.partLots|filter(l => l.instockUnknown == false) %}
|
||||||
|
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|
@ -42,20 +42,20 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<label class="col-form-label col-sm-3">
|
<label class="col-form-label {{ col_label }}">
|
||||||
{% trans %}part.info.withdraw_modal.comment{% endtrans %}
|
{% trans %}part.info.withdraw_modal.comment{% endtrans %}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
<input type="text" class="form-control" name="comment" value="" {% if event_comment_needed('part_stock_operation') %}required{% endif %}>
|
<input type="text" class="form-control" name="comment" value="" {% if event_comment_needed('part_stock_operation') %}required{% endif %}>
|
||||||
<div id="emailHelp" class="form-text">{% trans %}part.info.withdraw_modal.comment.hint{% endtrans %}</div>
|
<div id="emailHelp" class="form-text">{% trans %}part.info.withdraw_modal.comment.hint{% endtrans %}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<label class="col-form-label col-sm-3">
|
<label class="col-form-label {{ col_label }}">
|
||||||
{% trans %}part.info.withdraw_modal.timestamp{% endtrans %}
|
{% trans %}part.info.withdraw_modal.timestamp{% endtrans %}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-9">
|
<div class="{{ col_input }}">
|
||||||
{# The timestamp must be between a year ago and 1 hour in the future #}
|
{# The timestamp must be between a year ago and 1 hour in the future #}
|
||||||
<input type="datetime-local" class="form-control" name="timestamp" value=""
|
<input type="datetime-local" class="form-control" name="timestamp" value=""
|
||||||
max="{{ "+10mins"|date('Y-m-d\\TH:i') }}" min="{{ "-1year"|date('Y-m-d\\TH:i') }}">
|
max="{{ "+10mins"|date('Y-m-d\\TH:i') }}" min="{{ "-1year"|date('Y-m-d\\TH:i') }}">
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
{# The timestamp must be between a year ago and 1 hour in the future #}
|
{# The timestamp must be between a year ago and 1 hour in the future #}
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" name="delete_lot_if_empty" value="true" id="withdraw_modal_delete_if_empty">
|
<input class="form-check-input" type="checkbox" name="delete_lot_if_empty" value="true" id="withdraw_modal_delete_if_empty">
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
{{ form_row(filterForm.discard) }}
|
{{ form_row(filterForm.discard) }}
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-sm-9 offset-sm-3">
|
<div class="{{ col_input }} {{ offset_label }}">
|
||||||
<button type="button" class="btn btn-danger" {{ stimulus_action('helpers/form_cleanup', 'clearAll') }}>{% trans %}filter.clear_filters{% endtrans %}</button>
|
<button type="button" class="btn btn-danger" {{ stimulus_action('helpers/form_cleanup', 'clearAll') }}>{% trans %}filter.clear_filters{% endtrans %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
|
|
||||||
{% if displayTrustedOption %}
|
{% if displayTrustedOption %}
|
||||||
<div class="form-group row mt-3">
|
<div class="form-group row mt-3">
|
||||||
<div class="offset-3">
|
<div class="{{ offset_label }} {{ col_input }}">
|
||||||
<div class="custom-checkbox custom-control ms-2">
|
<div class="custom-checkbox custom-control ms-2">
|
||||||
<input id="_trusted" class="form-check-input" type="checkbox" name="{{ trustedParameterName }}" />
|
<input id="_trusted" class="form-check-input" type="checkbox" name="{{ trustedParameterName }}" />
|
||||||
<label class="form-check-label" for="_trusted">{% trans %}tfa.code.trusted_pc{% endtrans %}</label>
|
<label class="form-check-label" for="_trusted">{% trans %}tfa.code.trusted_pc{% endtrans %}</label>
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
|
|
||||||
{% block submit_btn %}
|
{% block submit_btn %}
|
||||||
<div class="form-group-row">
|
<div class="form-group-row">
|
||||||
<div class="offset-3">
|
<div class="{{ offset_label }} {{ col_input }}">
|
||||||
<button type="submit" class="btn btn-primary" value="{{ "login"|trans({}, 'SchebTwoFactorBundle') }}">{% trans %}login.btn{% endtrans %}</button>
|
<button type="submit" class="btn btn-primary" value="{{ "login"|trans({}, 'SchebTwoFactorBundle') }}">{% trans %}login.btn{% endtrans %}</button>
|
||||||
<a class="ms-2" href="{{ logoutPath }}">{% trans %}user.logout{% endtrans %}</a>
|
<a class="ms-2" href="{{ logoutPath }}">{% trans %}user.logout{% endtrans %}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,55 +20,25 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block card_content %}
|
{% block card_content %}
|
||||||
<form action="{{ path('login') }}" method="post" data-turbo="false" class="form-horizontal">
|
|
||||||
|
|
||||||
<input type="hidden" name="_csrf_token" data-controller="csrf-protection" value="{{ csrf_token('authenticate') }}">
|
{% if saml_enabled %}
|
||||||
|
<div class="{{ offset_label }} {{ col_input }}">
|
||||||
|
<a class="btn btn-secondary" href="{{ path('saml_login') }}"><i class="fa-solid fa-house-user"></i> {% trans %}login.sso_saml_login{% endtrans %}</a>
|
||||||
|
|
||||||
<input type="hidden" name="_target_path" value="{{ app.request.query.get('_target_path') }}" />
|
<p class="text-muted">{% trans %}login.local_login_hint{% endtrans %}</p>
|
||||||
|
|
||||||
{% if saml_enabled %}
|
|
||||||
<div class="col-md-9 offset-md-3 col-lg-10 offset-lg-2">
|
|
||||||
<a class="btn btn-secondary" href="{{ path('saml_login') }}"><i class="fa-solid fa-house-user"></i> {% trans %}login.sso_saml_login{% endtrans %}</a>
|
|
||||||
|
|
||||||
<p class="text-muted">{% trans %}login.local_login_hint{% endtrans %}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="form-group row">
|
|
||||||
<label class="col-form-label col-md-3 col-lg-2">{% trans %}login.username.label{% endtrans %}</label>
|
|
||||||
<div class="col-md-9 col-lg-10">
|
|
||||||
<input type="text" class="form-control" name="_username" value="{{ last_username }}"
|
|
||||||
placeholder="{% trans %}login.username.placeholder{% endtrans %}" autocomplete="username">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row">
|
|
||||||
<label class="col-form-label col-md-3 col-lg-2">{% trans %}login.password.label{% endtrans %}</label>
|
|
||||||
<div class="col-md-9 col-lg-10">
|
|
||||||
<input type="password" class="form-control" placeholder="{% trans %}login.password.placeholder{% endtrans %}" name="_password"
|
|
||||||
autocomplete="current-password">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-group row">
|
{{ form_start(form) }}
|
||||||
<div class="col-md-9 offset-md-3 col-lg-10 offset-lg-2">
|
|
||||||
<div class="custom-control custom-checkbox custom-control-inline">
|
|
||||||
<input class="form-check-input" name="_remember_me" id="remember_me" type="checkbox">
|
|
||||||
<label class="form-check-label" for="remember_me">
|
|
||||||
{% trans %}login.rememberme{% endtrans %}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group row mt-3">
|
{{ form_row(form._username) }}
|
||||||
<div class="col-md-9 offset-md-3 col-lg-10 offset-lg-2">
|
{{ form_row(form._password) }}
|
||||||
<button type="submit" class="btn btn-primary">{% trans %}login.btn{% endtrans %}</button>
|
{{ form_row(form._remember_me) }}
|
||||||
</div>
|
{{ form_row(form.submit) }}
|
||||||
</div>
|
|
||||||
</form>
|
{{ form_end(form) }}
|
||||||
|
|
||||||
{% if allow_email_pw_reset %}
|
{% if allow_email_pw_reset %}
|
||||||
<a class="offset-sm-2" href="{{ path('pw_reset_request') }}">{% trans %}pw_reset.password_forget{% endtrans %}</a>
|
<a class="{{ offset_label }}" href="{{ path('pw_reset_request') }}">{% trans %}pw_reset.password_forget{% endtrans %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,12 @@
|
||||||
|
|
||||||
{% if section_widget.vars.embedded_settings_metadata is defined %} {# Check if we have nested embedded settings or not #}
|
{% if section_widget.vars.embedded_settings_metadata is defined %} {# Check if we have nested embedded settings or not #}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend class="offset-3">
|
<legend class="{{ offset_label }}">
|
||||||
<i class="fa-solid {{ settings_icon(settings_object)|default('fa-sliders') }} fa-fw"></i>
|
<i class="fa-solid {{ settings_icon(settings_object)|default('fa-sliders') }} fa-fw"></i>
|
||||||
{{ (section_widget.vars.label ?? section_widget.vars.name|humanize)|trans }}
|
{{ (section_widget.vars.label ?? section_widget.vars.name|humanize)|trans }}
|
||||||
</legend>
|
</legend>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="offset-sm-3 col mb-3 ps-2">
|
<div class="{{ offset_label }} col mb-3 ps-2">
|
||||||
<b>{{ form_help(section_widget) }}</b>
|
<b>{{ form_help(section_widget) }}</b>
|
||||||
{{ form_errors(section_widget) }}
|
{{ form_errors(section_widget) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,11 @@
|
||||||
|
|
||||||
{{ form_start(google_form, { 'attr': google_form_attr}) }}
|
{{ form_start(google_form, { 'attr': google_form_attr}) }}
|
||||||
{% if not tfa_google.enabled %}
|
{% if not tfa_google.enabled %}
|
||||||
<div class="offset-sm-3">
|
<div class="{{ offset_label }}">
|
||||||
<h6>{% trans %}tfa_google.disabled_message{% endtrans %}</h6>
|
<h6>{% trans %}tfa_google.disabled_message{% endtrans %}</h6>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="offset-sm-3 row">
|
<div class="{{ offset_label }} row">
|
||||||
<div class="col-sm-3">
|
<div class="col-sm-3">
|
||||||
<img width="100%" class="img-fluid bg-white p-2" alt="{{ tfa_google.qrContent }}" src="{{ barcode_svg(tfa_google.qrContent) | data_uri("image/svg+xml") }}">
|
<img width="100%" class="img-fluid bg-white p-2" alt="{{ tfa_google.qrContent }}" src="{{ barcode_svg(tfa_google.qrContent) | data_uri("image/svg+xml") }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="offset-sm-3">
|
<div class="{{ offset_label }}">
|
||||||
<button class="btn btn-link" type="button" data-bs-toggle="collapse" data-bs-target="#manualSetupCollapse" aria-expanded="false" aria-controls="manualSetupCollapse">
|
<button class="btn btn-link" type="button" data-bs-toggle="collapse" data-bs-target="#manualSetupCollapse" aria-expanded="false" aria-controls="manualSetupCollapse">
|
||||||
{% trans %}tfa_google.manual_setup{% endtrans %}
|
{% trans %}tfa_google.manual_setup{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -72,7 +72,7 @@
|
||||||
|
|
||||||
{{ form_row(google_form.google_confirmation) }}
|
{{ form_row(google_form.google_confirmation) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="offset-sm-3">
|
<div class="{{ offset_label }}">
|
||||||
<h6>{% trans %}tfa_google.enabled_message{% endtrans %}</h6>
|
<h6>{% trans %}tfa_google.enabled_message{% endtrans %}</h6>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-pane fade" id="tfa-backup" role="tabpanel" aria-labelledby="backup-tab">
|
<div class="tab-pane fade" id="tfa-backup" role="tabpanel" aria-labelledby="backup-tab">
|
||||||
{% if user.backupCodes is empty %}
|
{% if user.backupCodes is empty %}
|
||||||
<div class="offset-sm-3">
|
<div class="{{ offset_label }}">
|
||||||
<h6>{% trans %}tfa_backup.disabled{% endtrans %}</h6>
|
<h6>{% trans %}tfa_backup.disabled{% endtrans %}</h6>
|
||||||
<span>{% trans %}tfa_backup.explanation{% endtrans %}</span>
|
<span>{% trans %}tfa_backup.explanation{% endtrans %}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,19 +89,19 @@
|
||||||
{% set backup_form_attr = { 'data-delete-form': true, 'data-controller': 'elements--delete-btn', 'data-action': 'submit->elements--delete-btn#submit',
|
{% set backup_form_attr = { 'data-delete-form': true, 'data-controller': 'elements--delete-btn', 'data-action': 'submit->elements--delete-btn#submit',
|
||||||
'data-delete-title': 'tfa_backup.reset_codes.confirm_title' | trans, 'data-delete-message': 'tfa_backup.reset_codes.confirm_message' | trans} %}
|
'data-delete-title': 'tfa_backup.reset_codes.confirm_title' | trans, 'data-delete-message': 'tfa_backup.reset_codes.confirm_message' | trans} %}
|
||||||
{{ form_start(backup_form, { 'attr': backup_form_attr}) }}
|
{{ form_start(backup_form, { 'attr': backup_form_attr}) }}
|
||||||
<div class="offset-sm-3">
|
<div class="{{ offset_label }}">
|
||||||
<h6>{% trans %}tfa_backup.enabled{% endtrans %}</h6>
|
<h6>{% trans %}tfa_backup.enabled{% endtrans %}</h6>
|
||||||
<span>{% trans %}tfa_backup.explanation{% endtrans %}</span>
|
<span>{% trans %}tfa_backup.explanation{% endtrans %}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="offset-sm-3 mt-2">
|
<div class="{{ offset_label }} mt-2">
|
||||||
<p class="mb-0"><b>{% trans %}tfa_backup.remaining_tokens{% endtrans %}:</b> {{ user.backupCodes | length }}</p>
|
<p class="mb-0"><b>{% trans %}tfa_backup.remaining_tokens{% endtrans %}:</b> {{ user.backupCodes | length }}</p>
|
||||||
<p><b>{% trans %}tfa_backup.generation_date{% endtrans %}:</b> {{ user.backupCodesGenerationDate | format_datetime }}</p>
|
<p><b>{% trans %}tfa_backup.generation_date{% endtrans %}:</b> {{ user.backupCodesGenerationDate | format_datetime }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="offset-sm-3">
|
<div class="{{ offset_label }}">
|
||||||
<a href="{{ path('show_backup_codes') }}" target="_blank" data-turbo="false" class="btn btn-primary">{% trans %}tfa_backup.show_codes{% endtrans %}</a>
|
<a href="{{ path('show_backup_codes') }}" target="_blank" data-turbo="false" class="btn btn-primary">{% trans %}tfa_backup.show_codes{% endtrans %}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="offset-sm-3 mt-2">
|
<div class="{{ offset_label }} mt-2">
|
||||||
{{ form_widget(backup_form.reset_codes) }}
|
{{ form_widget(backup_form.reset_codes) }}
|
||||||
</div>
|
</div>
|
||||||
{{ form_end(backup_form) }}
|
{{ form_end(backup_form) }}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
{{ form_row(settings_form.showEmailOnProfile) }}
|
{{ form_row(settings_form.showEmailOnProfile) }}
|
||||||
{{ form_row(settings_form.avatar_file) }}
|
{{ form_row(settings_form.avatar_file) }}
|
||||||
<div class="mb-3 row {% if user.masterPictureAttachment is null %}d-none{% endif %}">
|
<div class="mb-3 row {% if user.masterPictureAttachment is null %}d-none{% endif %}">
|
||||||
<div class="offset-sm-3 col-sm-9">
|
<div class="{{ offset_label }} {{ col_input }}">
|
||||||
{% if user.masterPictureAttachment %}
|
{% if user.masterPictureAttachment %}
|
||||||
<img src="{{ attachment_thumbnail(user.masterPictureAttachment, 'thumbnail_md') }}" alt="avatar" class="rounded" style="height: 75px;">
|
<img src="{{ attachment_thumbnail(user.masterPictureAttachment, 'thumbnail_md') }}" alt="avatar" class="rounded" style="height: 75px;">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,32 @@ final class PartLotsEndpointTest extends CrudEndpointTestCase
|
||||||
$this->_testGetItem(2);
|
$this->_testGetItem(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testFilterByUserBarcode(): void
|
||||||
|
{
|
||||||
|
$response = self::createAuthenticatedClient()->request('GET', '/api/part_lots?user_barcode=lot2_vendor_barcode');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertJsonContains([
|
||||||
|
'hydra:totalItems' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$json = $response->toArray();
|
||||||
|
self::assertSame('/api/part_lots/2', $json['hydra:member'][0]['@id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFilterByUserBarcodeUsingWildcard(): void
|
||||||
|
{
|
||||||
|
$response = self::createAuthenticatedClient()->request('GET', '/api/part_lots?user_barcode=lot2_%');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertJsonContains([
|
||||||
|
'hydra:totalItems' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$json = $response->toArray();
|
||||||
|
self::assertSame('/api/part_lots/2', $json['hydra:member'][0]['@id']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testCreateItem(): void
|
public function testCreateItem(): void
|
||||||
{
|
{
|
||||||
$this->_testPostItem([
|
$this->_testPostItem([
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||||
'exclude_from_bom' => 'False',
|
'exclude_from_bom' => 'False',
|
||||||
'exclude_from_board' => 'True',
|
'exclude_from_board' => 'True',
|
||||||
'exclude_from_sim' => 'False',
|
'exclude_from_sim' => 'False',
|
||||||
|
'description' => '',
|
||||||
'fields' =>
|
'fields' =>
|
||||||
array(
|
array(
|
||||||
'footprint' =>
|
'footprint' =>
|
||||||
|
|
@ -203,6 +204,7 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||||
'exclude_from_bom' => 'False',
|
'exclude_from_bom' => 'False',
|
||||||
'exclude_from_board' => 'True',
|
'exclude_from_board' => 'True',
|
||||||
'exclude_from_sim' => 'False',
|
'exclude_from_sim' => 'False',
|
||||||
|
'description' => '',
|
||||||
'fields' =>
|
'fields' =>
|
||||||
array (
|
array (
|
||||||
'footprint' =>
|
'footprint' =>
|
||||||
|
|
|
||||||
381
tests/Controller/UpdateManagerControllerTest.php
Normal file
381
tests/Controller/UpdateManagerControllerTest.php
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
<?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\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
|
use App\Services\System\BackupManager;
|
||||||
|
use App\Services\System\UpdateExecutor;
|
||||||
|
use PHPUnit\Framework\Attributes\Group;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
#[Group("slow")]
|
||||||
|
#[Group("DB")]
|
||||||
|
final class UpdateManagerControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private function loginAsAdmin($client): void
|
||||||
|
{
|
||||||
|
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||||
|
$userRepository = $entityManager->getRepository(User::class);
|
||||||
|
$user = $userRepository->findOneBy(['name' => 'admin']);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$this->markTestSkipped('Admin user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$client->loginUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a CSRF token from the rendered update manager page.
|
||||||
|
*/
|
||||||
|
private function getCsrfTokenFromPage($crawler, string $formAction): string
|
||||||
|
{
|
||||||
|
$form = $crawler->filter('form[action*="' . $formAction . '"]');
|
||||||
|
if ($form->count() === 0) {
|
||||||
|
$this->fail('Form with action containing "' . $formAction . '" not found on page');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $form->filter('input[name="_token"]')->attr('value');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Authentication tests ----
|
||||||
|
|
||||||
|
public function testIndexPageRequiresAuth(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$client->request('GET', '/en/system/update-manager');
|
||||||
|
|
||||||
|
// Should deny access (401 with HTTP Basic auth in test env)
|
||||||
|
$this->assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexPageAccessibleByAdmin(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
$client->request('GET', '/en/system/update-manager');
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Backup creation tests ----
|
||||||
|
|
||||||
|
public function testCreateBackupRequiresCsrf(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
$client->request('POST', '/en/system/update-manager/backup', [
|
||||||
|
'_token' => 'invalid',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should redirect with error flash
|
||||||
|
$this->assertResponseRedirects();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateBackupWithValidCsrf(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
// Load the page and extract CSRF token from the backup form
|
||||||
|
$crawler = $client->request('GET', '/en/system/update-manager');
|
||||||
|
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup');
|
||||||
|
|
||||||
|
$client->request('POST', '/en/system/update-manager/backup', [
|
||||||
|
'_token' => $csrfToken,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects();
|
||||||
|
|
||||||
|
// Clean up: delete the backup that was just created
|
||||||
|
$backupManager = $client->getContainer()->get(BackupManager::class);
|
||||||
|
$backups = $backupManager->getBackups();
|
||||||
|
foreach ($backups as $backup) {
|
||||||
|
if (str_contains($backup['file'], 'manual')) {
|
||||||
|
$backupManager->deleteBackup($backup['file']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateBackupBlockedWhenLocked(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
// Load the page first to get CSRF token before locking
|
||||||
|
$crawler = $client->request('GET', '/en/system/update-manager');
|
||||||
|
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup');
|
||||||
|
|
||||||
|
// Acquire lock to simulate update in progress
|
||||||
|
$updateExecutor = $client->getContainer()->get(UpdateExecutor::class);
|
||||||
|
$updateExecutor->acquireLock();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client->request('POST', '/en/system/update-manager/backup', [
|
||||||
|
'_token' => $csrfToken,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects();
|
||||||
|
} finally {
|
||||||
|
// Always release lock
|
||||||
|
$updateExecutor->releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Backup deletion tests ----
|
||||||
|
|
||||||
|
public function testDeleteBackupRequiresCsrf(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
$client->request('POST', '/en/system/update-manager/backup/delete', [
|
||||||
|
'_token' => 'invalid',
|
||||||
|
'filename' => 'test.zip',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteBackupWithValidCsrf(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
// Create a temporary backup file so the page shows the delete form
|
||||||
|
$backupManager = $client->getContainer()->get(BackupManager::class);
|
||||||
|
$backupDir = $backupManager->getBackupDir();
|
||||||
|
if (!is_dir($backupDir)) {
|
||||||
|
mkdir($backupDir, 0755, true);
|
||||||
|
}
|
||||||
|
$testFile = 'test-delete-' . uniqid() . '.zip';
|
||||||
|
file_put_contents($backupDir . '/' . $testFile, 'test');
|
||||||
|
|
||||||
|
// Load the page and extract CSRF token from the delete form
|
||||||
|
$crawler = $client->request('GET', '/en/system/update-manager');
|
||||||
|
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup/delete');
|
||||||
|
|
||||||
|
$client->request('POST', '/en/system/update-manager/backup/delete', [
|
||||||
|
'_token' => $csrfToken,
|
||||||
|
'filename' => $testFile,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects();
|
||||||
|
$this->assertFileDoesNotExist($backupDir . '/' . $testFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Log deletion tests ----
|
||||||
|
|
||||||
|
public function testDeleteLogRequiresCsrf(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
$client->request('POST', '/en/system/update-manager/log/delete', [
|
||||||
|
'_token' => 'invalid',
|
||||||
|
'filename' => 'test.log',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteLogWithValidCsrf(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
// Create a temporary log file so the page shows the delete form
|
||||||
|
$projectDir = $client->getContainer()->getParameter('kernel.project_dir');
|
||||||
|
$logDir = $projectDir . '/var/log/updates';
|
||||||
|
if (!is_dir($logDir)) {
|
||||||
|
mkdir($logDir, 0755, true);
|
||||||
|
}
|
||||||
|
$testFile = 'update-test-delete-' . uniqid() . '.log';
|
||||||
|
file_put_contents($logDir . '/' . $testFile, 'test log content');
|
||||||
|
|
||||||
|
// Load the page and extract CSRF token from the log delete form
|
||||||
|
$crawler = $client->request('GET', '/en/system/update-manager');
|
||||||
|
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'log/delete');
|
||||||
|
|
||||||
|
$client->request('POST', '/en/system/update-manager/log/delete', [
|
||||||
|
'_token' => $csrfToken,
|
||||||
|
'filename' => $testFile,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects();
|
||||||
|
$this->assertFileDoesNotExist($logDir . '/' . $testFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Backup download tests ----
|
||||||
|
|
||||||
|
public function testDownloadBackupBlockedByDefault(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
// DISABLE_BACKUP_DOWNLOAD=1 is the default in .env, so this should return 403
|
||||||
|
$client->request('POST', '/en/system/update-manager/backup/download', [
|
||||||
|
'_token' => 'any',
|
||||||
|
'filename' => 'test.zip',
|
||||||
|
'password' => 'test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDownloadBackupRequiresPost(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
// GET returns 404 since no GET route exists for this path
|
||||||
|
$client->request('GET', '/en/system/update-manager/backup/download');
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDownloadBackupRequiresAuth(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$client->request('POST', '/en/system/update-manager/backup/download', [
|
||||||
|
'_token' => 'any',
|
||||||
|
'filename' => 'test.zip',
|
||||||
|
'password' => 'test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should deny access (401 with HTTP Basic auth in test env)
|
||||||
|
$this->assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Backup details tests ----
|
||||||
|
|
||||||
|
public function testBackupDetailsReturns404ForNonExistent(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
$client->request('GET', '/en/system/update-manager/backup/nonexistent.zip');
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Restore tests ----
|
||||||
|
|
||||||
|
public function testRestoreBlockedWhenDisabled(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
// DISABLE_BACKUP_RESTORE=1 is the default in .env, so this should return 403
|
||||||
|
$client->request('POST', '/en/system/update-manager/restore', [
|
||||||
|
'_token' => 'invalid',
|
||||||
|
'filename' => 'test.zip',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRestoreRequiresAuth(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$client->request('POST', '/en/system/update-manager/restore', [
|
||||||
|
'_token' => 'invalid',
|
||||||
|
'filename' => 'test.zip',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Start update tests ----
|
||||||
|
|
||||||
|
public function testStartUpdateRequiresAuth(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$client->request('POST', '/en/system/update-manager/start', [
|
||||||
|
'_token' => 'invalid',
|
||||||
|
'version' => 'v1.0.0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStartUpdateBlockedWhenWebUpdatesDisabled(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
// DISABLE_WEB_UPDATES=1 is the default in .env
|
||||||
|
$client->request('POST', '/en/system/update-manager/start', [
|
||||||
|
'_token' => 'invalid',
|
||||||
|
'version' => 'v1.0.0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Status and progress tests ----
|
||||||
|
|
||||||
|
public function testStatusEndpointRequiresAuth(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$client->request('GET', '/en/system/update-manager/status');
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStatusEndpointAccessibleByAdmin(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
$client->request('GET', '/en/system/update-manager/status');
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProgressStatusEndpointRequiresAuth(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$client->request('GET', '/en/system/update-manager/progress/status');
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProgressStatusEndpointAccessibleByAdmin(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$this->loginAsAdmin($client);
|
||||||
|
|
||||||
|
$client->request('GET', '/en/system/update-manager/progress/status');
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -85,4 +85,41 @@ final class StructuralElementDenormalizerTest extends WebTestCase
|
||||||
$result2 = $this->service->denormalize($data, Category::class, 'json', ['groups' => ['import']]);
|
$result2 = $this->service->denormalize($data, Category::class, 'json', ['groups' => ['import']]);
|
||||||
$this->assertSame($result, $result2);
|
$this->assertSame($result, $result2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testDenormalizeViaChildren(): void
|
||||||
|
{
|
||||||
|
$data = ['name' => 'Node',
|
||||||
|
'children' => [
|
||||||
|
['name' => 'A', 'children' => [['name' => '1'], ['name' => '2']]],
|
||||||
|
['name' => 'B', 'children' => [['name' => '1'], ['name' => '2']]],
|
||||||
|
['name' => 'C', 'children' => [['name' => '1'], ['name' => '2'], ['name' => '3']]],
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->service->denormalize($data, Category::class, 'json', ['groups' => ['import', 'include_children']]);
|
||||||
|
$this->assertInstanceOf(Category::class, $result);
|
||||||
|
|
||||||
|
$this->assertCount(3, $result->getChildren());
|
||||||
|
$this->assertSame('A', $result->getChildren()[0]->getName());
|
||||||
|
$this->assertSame('B', $result->getChildren()[1]->getName());
|
||||||
|
$this->assertSame('C', $result->getChildren()[2]->getName());
|
||||||
|
//Parents should be set correctly
|
||||||
|
$this->assertSame($result, $result->getChildren()[0]->getParent());
|
||||||
|
$this->assertSame($result, $result->getChildren()[1]->getParent());
|
||||||
|
$this->assertSame($result, $result->getChildren()[2]->getParent());
|
||||||
|
|
||||||
|
$this->assertCount(2, $result->getChildren()[0]->getChildren());
|
||||||
|
$this->assertSame('1', $result->getChildren()[0]->getChildren()[0]->getName());
|
||||||
|
$this->assertSame('2', $result->getChildren()[0]->getChildren()[1]->getName());
|
||||||
|
//Parents should be set correctly
|
||||||
|
$this->assertSame($result->getChildren()[0], $result->getChildren()[0]->getChildren()[0]->getParent());
|
||||||
|
$this->assertSame($result->getChildren()[0], $result->getChildren()[0]->getChildren()[1]->getParent());
|
||||||
|
|
||||||
|
$this->assertCount(2, $result->getChildren()[1]->getChildren());
|
||||||
|
$this->assertSame('1', $result->getChildren()[1]->getChildren()[0]->getName());
|
||||||
|
$this->assertSame('2', $result->getChildren()[1]->getChildren()[1]->getName());
|
||||||
|
//Must be different instances than the children of A, because we create new elements for the same path, if we don't have them in the DB
|
||||||
|
$this->assertNotSame($result->getChildren()[0]->getChildren()[0], $result->getChildren()[1]->getChildren()[0]);
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,8 @@ final class BarcodeScanHelperTest extends WebTestCase
|
||||||
yield [new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 2,BarcodeSourceType::USER_DEFINED),
|
yield [new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 2,BarcodeSourceType::USER_DEFINED),
|
||||||
'lot2_vendor_barcode'];
|
'lot2_vendor_barcode'];
|
||||||
|
|
||||||
|
|
||||||
|
$input = "[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04";
|
||||||
$eigp114Result = new EIGP114BarcodeScanResult([
|
$eigp114Result = new EIGP114BarcodeScanResult([
|
||||||
'P' => '596-777A1-ND',
|
'P' => '596-777A1-ND',
|
||||||
'1P' => 'XAF4444',
|
'1P' => 'XAF4444',
|
||||||
|
|
@ -122,9 +124,9 @@ final class BarcodeScanHelperTest extends WebTestCase
|
||||||
'10D' => '1452',
|
'10D' => '1452',
|
||||||
'1T' => 'BF1103',
|
'1T' => 'BF1103',
|
||||||
'4L' => 'US',
|
'4L' => 'US',
|
||||||
]);
|
], $input);
|
||||||
|
|
||||||
yield [$eigp114Result, "[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04"];
|
yield [$eigp114Result, $input];
|
||||||
|
|
||||||
$lcscInput = '{pc:C138033,pm:RC0402FR-071ML,qty:10}';
|
$lcscInput = '{pc:C138033,pm:RC0402FR-071ML,qty:10}';
|
||||||
$lcscResult = new LCSCBarcodeScanResult(
|
$lcscResult = new LCSCBarcodeScanResult(
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,16 @@ final class BackupManagerTest extends KernelTestCase
|
||||||
$this->assertSame('2.6.0', $matches[2]);
|
$this->assertSame('2.6.0', $matches[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testDeleteBackupReturnsFalseForNonExistentFile(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->backupManager->deleteBackup('non-existent.zip'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteBackupReturnsFalseForNonZipFile(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->backupManager->deleteBackup('not-a-zip.txt'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test version parsing with different filename formats.
|
* Test version parsing with different filename formats.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,38 @@ final class UpdateExecutorTest extends KernelTestCase
|
||||||
$this->assertFalse($this->updateExecutor->isLocked());
|
$this->assertFalse($this->updateExecutor->isLocked());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testDeleteLogRejectsInvalidFilename(): void
|
||||||
|
{
|
||||||
|
// Path traversal attempts should be rejected
|
||||||
|
$this->assertFalse($this->updateExecutor->deleteLog('../../../etc/passwd'));
|
||||||
|
$this->assertFalse($this->updateExecutor->deleteLog('malicious.txt'));
|
||||||
|
$this->assertFalse($this->updateExecutor->deleteLog(''));
|
||||||
|
// Must start with "update-"
|
||||||
|
$this->assertFalse($this->updateExecutor->deleteLog('backup-v1.0.0.log'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteLogReturnsFalseForNonExistentFile(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->updateExecutor->deleteLog('update-nonexistent-file.log'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteLogDeletesExistingFile(): void
|
||||||
|
{
|
||||||
|
// Create a temporary log file in the update logs directory
|
||||||
|
$projectDir = self::getContainer()->getParameter('kernel.project_dir');
|
||||||
|
$logDir = $projectDir . '/var/log/updates';
|
||||||
|
|
||||||
|
if (!is_dir($logDir)) {
|
||||||
|
mkdir($logDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$testFile = 'update-test-delete-' . uniqid() . '.log';
|
||||||
|
file_put_contents($logDir . '/' . $testFile, 'test log content');
|
||||||
|
|
||||||
|
$this->assertTrue($this->updateExecutor->deleteLog($testFile));
|
||||||
|
$this->assertFileDoesNotExist($logDir . '/' . $testFile);
|
||||||
|
}
|
||||||
|
|
||||||
public function testEnableAndDisableMaintenanceMode(): void
|
public function testEnableAndDisableMaintenanceMode(): void
|
||||||
{
|
{
|
||||||
// First, ensure maintenance mode is off
|
// First, ensure maintenance mode is off
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue