mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-26 19:52:37 +01:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
636776c531 | ||
|
|
ca4a33d408 | ||
|
|
9db158f4d4 | ||
|
|
ea8b179df1 | ||
|
|
efc152e3c8 | ||
|
|
e68827bf3b | ||
|
|
58bf69882f | ||
|
|
915f313efd | ||
|
|
52d29099a2 | ||
|
|
c06fc926a1 | ||
|
|
7640ed08bc | ||
|
|
9c4e9066f9 | ||
|
|
b4d1af2bce | ||
|
|
5ec676c40c | ||
|
|
5096aea5bb | ||
|
|
feedd190dc | ||
|
|
3423fffaca | ||
|
|
1624fd2e28 | ||
|
|
10b3094d5e | ||
|
|
580e638f67 | ||
|
|
e44428f87c | ||
|
|
379f7ef865 | ||
|
|
427f6e4d55 | ||
|
|
07a1e9fc3c | ||
|
|
78d64e8f1b | ||
|
|
559a9a9f3e | ||
|
|
ac6dd23fd6 | ||
|
|
1e515df0b5 | ||
|
|
35490762a6 | ||
|
|
c25e23d3d9 | ||
|
|
8bb8257e62 | ||
|
|
5f096927bd | ||
|
|
434826c125 | ||
|
|
89595cd5dc | ||
|
|
d991e15a94 | ||
|
|
6a1aefa5a5 | ||
|
|
272684e7eb | ||
|
|
9be3eba694 | ||
|
|
5a3fc0fb43 | ||
|
|
47ef8e9568 | ||
|
|
e4285bbc78 | ||
|
|
49b6a42791 | ||
|
|
b62fd602f2 | ||
|
|
923e40ed8f | ||
|
|
3c724a227a | ||
|
|
90d26eb16a | ||
|
|
b629744e1a | ||
|
|
b0ab43c39a | ||
|
|
2c33b381c1 | ||
|
|
c50a80e8df | ||
|
|
1534f780aa | ||
|
|
4c6ceab8e8 | ||
|
|
f3fc01b740 | ||
|
|
a201be5a01 | ||
|
|
ebf2035351 | ||
|
|
69fc28d5d6 | ||
|
|
4107535b19 |
@@ -26,10 +26,11 @@
|
||||
|
||||
# Pass the configuration from the docker env to the PHP environment (here you should list all .env options)
|
||||
PassEnv APP_ENV APP_DEBUG APP_SECRET
|
||||
PassEnv TRUSTED_PROXIES TRUSTED_HOSTS LOCK_DSN
|
||||
PassEnv DATABASE_URL ENFORCE_CHANGE_COMMENTS_FOR
|
||||
PassEnv DEFAULT_LANG DEFAULT_TIMEZONE BASE_CURRENCY INSTANCE_NAME ALLOW_ATTACHMENT_DOWNLOADS USE_GRAVATAR MAX_ATTACHMENT_FILE_SIZE DEFAULT_URI
|
||||
PassEnv MAILER_DSN ALLOW_EMAIL_PW_RESET EMAIL_SENDER_EMAIL EMAIL_SENDER_NAME
|
||||
PassEnv HISTORY_SAVE_CHANGED_FIELDS HISTORY_SAVE_CHANGED_DATA HISTORY_SAVE_REMOVED_DATA
|
||||
PassEnv HISTORY_SAVE_CHANGED_FIELDS HISTORY_SAVE_CHANGED_DATA HISTORY_SAVE_REMOVED_DATA HISTORY_SAVE_NEW_DATA
|
||||
PassEnv ERROR_PAGE_ADMIN_EMAIL ERROR_PAGE_SHOW_HELP
|
||||
PassEnv DEMO_MODE NO_URL_REWRITE_AVAILABLE FIXER_API_KEY BANNER
|
||||
PassEnv SAML_ENABLED SAML_ROLE_MAPPING SAML_UPDATE_GROUP_ON_LOGIN SAML_IDP_ENTITY_ID SAML_IDP_SINGLE_SIGN_ON_SERVICE SAML_IDP_SINGLE_LOGOUT_SERVICE SAML_IDP_X509_CERT SAML_SP_ENTITY_ID SAML_SP_X509_CERT SAMLP_SP_PRIVATE_KEY
|
||||
|
||||
3
.env
3
.env
@@ -71,6 +71,9 @@ HISTORY_SAVE_CHANGED_FIELDS=1
|
||||
HISTORY_SAVE_CHANGED_DATA=1
|
||||
# Save the data of an element that gets removed into log entry. This allows to undelete an element
|
||||
HISTORY_SAVE_REMOVED_DATA=1
|
||||
# Save the new data of an element that gets changed or added. This allows an easy comparison of the old and new data on the detail page
|
||||
# This option only becomes active when HISTORY_SAVE_CHANGED_DATA is set to 1
|
||||
HISTORY_SAVE_NEW_DATA=1
|
||||
|
||||
###################################################################################
|
||||
# Error pages settings
|
||||
|
||||
54
.github/workflows/codeql-analysis.yml
vendored
54
.github/workflows/codeql-analysis.yml
vendored
@@ -1,54 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: '0 14 * * 3'
|
||||
|
||||
jobs:
|
||||
analyse:
|
||||
name: Analyse
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
# with:
|
||||
# languages: go, javascript, csharp, python, cpp, java
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
@@ -44,6 +44,7 @@ export default class extends Controller
|
||||
const title = this.element.dataset.deleteTitle;
|
||||
|
||||
const form = this.element;
|
||||
const submitter = event.submitter;
|
||||
const that = this;
|
||||
|
||||
const confirm = bootbox.confirm({
|
||||
@@ -58,6 +59,14 @@ export default class extends Controller
|
||||
const submit_btn = document.createElement('button');
|
||||
submit_btn.type = 'submit';
|
||||
submit_btn.style.display = 'none';
|
||||
|
||||
//If the clicked button has a value, set it on the submit button
|
||||
if (submitter.value) {
|
||||
submit_btn.value = submitter.value;
|
||||
}
|
||||
if (submitter.name) {
|
||||
submit_btn.name = submitter.name;
|
||||
}
|
||||
form.appendChild(submit_btn);
|
||||
submit_btn.click();
|
||||
} else {
|
||||
|
||||
40
assets/controllers/elements/json_formatter_controller.js
Normal file
40
assets/controllers/elements/json_formatter_controller.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
import JSONFormatter from 'json-formatter-js';
|
||||
|
||||
/**
|
||||
* This controller implements an element that renders a JSON object as a collapsible tree.
|
||||
* The JSON object is passed as a data attribute.
|
||||
* You have to apply the controller to a div element or similar block element which can contain other elements.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
const depth_to_open = this.element.dataset.depthToOpen ?? 0;
|
||||
const json_string = this.element.dataset.json;
|
||||
const json_object = JSON.parse(json_string);
|
||||
|
||||
const formatter = new JSONFormatter(json_object, depth_to_open);
|
||||
|
||||
this.element.appendChild(formatter.render());
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,29 @@ export default class extends Controller {
|
||||
'<small class="text-muted float-end">(' + addHint +')</small>' +
|
||||
'</div>';
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
//Add callbacks to update validity
|
||||
onInitialize: this.updateValidity.bind(this),
|
||||
onChange: this.updateValidity.bind(this),
|
||||
};
|
||||
|
||||
this._tomSelect = new TomSelect(this.element, settings);
|
||||
this._tomSelect.sync();
|
||||
}
|
||||
|
||||
|
||||
updateValidity() {
|
||||
//Mark this input as invalid, if the selected option is disabled
|
||||
|
||||
const input = this.element;
|
||||
const selectedOption = input.options[input.selectedIndex];
|
||||
|
||||
if (selectedOption && selectedOption.disabled) {
|
||||
input.setCustomValidity("This option was disabled. Please select another option.");
|
||||
} else {
|
||||
input.setCustomValidity("");
|
||||
}
|
||||
}
|
||||
|
||||
getTomSelect() {
|
||||
|
||||
@@ -105,4 +105,19 @@ form .col-form-label.required:after, form label.required:after {
|
||||
position: relative;
|
||||
right: -2px;
|
||||
z-index: 700;
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* HTML diff styling
|
||||
****************************************/
|
||||
|
||||
/* Insertations are marked with green background and bold */
|
||||
ins {
|
||||
background-color: #95f095;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
del {
|
||||
background-color: #f09595;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -4,16 +4,17 @@
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-dom": "*",
|
||||
"beberlei/doctrineextensions": "^1.2",
|
||||
"brick/math": "^0.8.15",
|
||||
"composer/package-versions-deprecated": "1.11.99.4",
|
||||
"doctrine/annotations": "^1.6",
|
||||
"doctrine/data-fixtures": "^1.6.6",
|
||||
"doctrine/dbal": "^3.4.6",
|
||||
"doctrine/doctrine-bundle": "^2.0",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.0",
|
||||
@@ -25,6 +26,7 @@
|
||||
"gregwar/captcha-bundle": "^2.1.0",
|
||||
"hslavich/oneloginsaml-bundle": "^2.10",
|
||||
"jbtronics/2fa-webauthn": "^1.0.0",
|
||||
"jfcherng/php-diff": "^6.14",
|
||||
"league/csv": "^9.8.0",
|
||||
"league/html-to-markdown": "^5.0.1",
|
||||
"liip/imagine-bundle": "^2.2",
|
||||
@@ -78,9 +80,7 @@
|
||||
"twig/intl-extra": "^3.0",
|
||||
"twig/markdown-extra": "^3.0",
|
||||
"web-auth/webauthn-symfony-bundle": "^3.3",
|
||||
"webmozart/assert": "^1.4",
|
||||
"doctrine/data-fixtures": "^1.6.6"
|
||||
|
||||
"webmozart/assert": "^1.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"dama/doctrine-test-bundle": "^7.0",
|
||||
|
||||
829
composer.lock
generated
829
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -125,3 +125,9 @@ parameters:
|
||||
env(DEFAULT_URI): 'https://partdb.changeme.invalid/'
|
||||
|
||||
env(SAML_ROLE_MAPPING): '{}'
|
||||
|
||||
env(HISTORY_SAVE_CHANGED_DATA): 1
|
||||
env(HISTORY_SAVE_CHANGED_FIELDS): 1
|
||||
env(HISTORY_SAVE_REMOVED_DATA): 1
|
||||
env(HISTORY_SAVE_NEW_DATA): 1
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ services:
|
||||
$save_changed_fields: '%env(bool:HISTORY_SAVE_CHANGED_FIELDS)%'
|
||||
$save_changed_data: '%env(bool:HISTORY_SAVE_CHANGED_DATA)%'
|
||||
$save_removed_data: '%env(bool:HISTORY_SAVE_REMOVED_DATA)%'
|
||||
$save_new_data: '%env(bool:HISTORY_SAVE_NEW_DATA)%'
|
||||
tags:
|
||||
- { name: 'doctrine.event_subscriber' }
|
||||
|
||||
|
||||
@@ -46,8 +46,9 @@ The following configuration options can only be changed by the server administra
|
||||
### History/Eventlog related settings
|
||||
The following options are used to configure, which (and how much) data is written to the system log:
|
||||
* `HISTORY_SAVE_CHANGED_FIELDS`: When this option is set to true, the name of the fields which are changed, are saved to the DB (so for example it is logged that a user has changed, that the user has changed the name and description of the field, but not the data/content of these changes)
|
||||
* `HISTORY_SAVE_CHANGED_DATA`: When this option is set to true, the changed data is saved to log (so it is logged, that a user has changed the name of a part and what the name was before). This can increase database size, when you have a lot of changes to enties.
|
||||
* `HISTORY_SAVE_CHANGED_DATA`: When this option is set to true, the changed data is saved to log (so it is logged, that a user has changed the name of a part and what the name was before). This can increase database size, when you have a lot of changes to entities.
|
||||
* `HISTORY_SAVE_REMOVED_DATA`: When this option is set to true, removed data is saved to log, meaning that you can easily undelete an entity, when it was removed accidentally.
|
||||
* `HISTORY_SAVE_NEW_DATA`: When this option is set to true, the new data (the data after a change) is saved to element changed log entries. This allows you to easily see the changes between two revisions of an entity. This can increase database size, when you have a lot of changes to entities.
|
||||
|
||||
If you wanna use want to revert changes or view older revisions of entities, then `HISTORY_SAVE_CHANGED_FIELDS`, `HISTORY_SAVE_CHANGED_DATA` and `HISTORY_SAVE_REMOVED_DATA` all have to be true.
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"emoji.json": "^14.0.0",
|
||||
"exports-loader": "^3.0.0",
|
||||
"html5-qrcode": "^2.2.1",
|
||||
"json-formatter-js": "^2.3.4",
|
||||
"jszip": "^3.2.0",
|
||||
"katex": "^0.16.0",
|
||||
"marked": "^4.3.0",
|
||||
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\DataTables\Column\LogEntryTargetColumn;
|
||||
use App\DataTables\Filters\LogFilter;
|
||||
use App\DataTables\LogDataTable;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
@@ -33,6 +34,9 @@ use App\Entity\LogSystem\ElementEditedLogEntry;
|
||||
use App\Form\Filters\LogFilterType;
|
||||
use App\Repository\DBElementRepository;
|
||||
use App\Services\LogSystem\EventUndoHelper;
|
||||
use App\Services\LogSystem\LogEntryExtraFormatter;
|
||||
use App\Services\LogSystem\LogLevelHelper;
|
||||
use App\Services\LogSystem\LogTargetHelper;
|
||||
use App\Services\LogSystem\TimeTravel;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
@@ -93,6 +97,51 @@ class LogController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route("/{id}/details", name="log_details")
|
||||
* @param Request $request
|
||||
* @param AbstractLogEntry $logEntry
|
||||
* @return Response
|
||||
*/
|
||||
public function logDetails(Request $request, AbstractLogEntry $logEntry, LogEntryExtraFormatter $logEntryExtraFormatter,
|
||||
LogLevelHelper $logLevelHelper, LogTargetHelper $logTargetHelper, EntityManagerInterface $entityManager): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('show_details', $logEntry);
|
||||
|
||||
$extra_html = $logEntryExtraFormatter->format($logEntry);
|
||||
$target_html = $logTargetHelper->formatTarget($logEntry);
|
||||
|
||||
$repo = $entityManager->getRepository(AbstractLogEntry::class);
|
||||
$target_element = $repo->getTargetElement($logEntry);
|
||||
|
||||
return $this->render('log_system/details/log_details.html.twig', [
|
||||
'log_entry' => $logEntry,
|
||||
'target_element' => $target_element,
|
||||
'extra_html' => $extra_html,
|
||||
'target_html' => $target_html,
|
||||
'log_level_helper' => $logLevelHelper,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route("/{id}/delete", name="log_delete", methods={"DELETE"})
|
||||
*/
|
||||
public function deleteLogEntry(Request $request, AbstractLogEntry $logEntry, EntityManagerInterface $entityManager): RedirectResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('delete', $logEntry);
|
||||
|
||||
if ($this->isCsrfTokenValid('delete'.$logEntry->getId(), $request->request->get('_token'))) {
|
||||
//Remove part
|
||||
$entityManager->remove($logEntry);
|
||||
//Flush changes
|
||||
$entityManager->flush();
|
||||
$this->addFlash('success', 'log.delete.success');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @Route("/undo", name="log_undo", methods={"POST"})
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\DataTables\ErrorDataTable;
|
||||
use App\DataTables\Filters\PartFilter;
|
||||
use App\DataTables\Filters\PartSearchFilter;
|
||||
use App\DataTables\PartsDataTable;
|
||||
@@ -30,9 +31,11 @@ use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\Storelocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Exceptions\InvalidRegexException;
|
||||
use App\Form\Filters\PartFilterType;
|
||||
use App\Services\Parts\PartsTableActionHandler;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use Doctrine\DBAL\Exception\DriverException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -41,6 +44,7 @@ use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class PartListsController extends AbstractController
|
||||
{
|
||||
@@ -48,11 +52,14 @@ class PartListsController extends AbstractController
|
||||
private NodesListBuilder $nodesListBuilder;
|
||||
private DataTableFactory $dataTableFactory;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager, NodesListBuilder $nodesListBuilder, DataTableFactory $dataTableFactory)
|
||||
private TranslatorInterface $translator;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager, NodesListBuilder $nodesListBuilder, DataTableFactory $dataTableFactory, TranslatorInterface $translator)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->nodesListBuilder = $nodesListBuilder;
|
||||
$this->dataTableFactory = $dataTableFactory;
|
||||
$this->translator = $translator;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +151,21 @@ class PartListsController extends AbstractController
|
||||
->handleRequest($request);
|
||||
|
||||
if ($table->isCallback()) {
|
||||
return $table->getResponse();
|
||||
try {
|
||||
try {
|
||||
return $table->getResponse();
|
||||
} catch (DriverException $driverException) {
|
||||
if ($driverException->getCode() === 1139) {
|
||||
//Convert the driver exception to InvalidRegexException so it has the same hanlder as for SQLite
|
||||
throw InvalidRegexException::fromDriverException($driverException);
|
||||
} else {
|
||||
throw $driverException;
|
||||
}
|
||||
}
|
||||
} catch (InvalidRegexException $exception) {
|
||||
$errors = $this->translator->trans('part.table.invalid_regex').': '.$exception->getReason();
|
||||
return ErrorDataTable::errorTable($this->dataTableFactory, $request, $errors);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render($template, array_merge([
|
||||
@@ -288,21 +309,22 @@ class PartListsController extends AbstractController
|
||||
{
|
||||
$filter = new PartSearchFilter($request->query->get('keyword', ''));
|
||||
|
||||
$filter->setName($request->query->getBoolean('name', true));
|
||||
$filter->setCategory($request->query->getBoolean('category', true));
|
||||
$filter->setDescription($request->query->getBoolean('description', true));
|
||||
$filter->setMpn($request->query->getBoolean('mpn', true));
|
||||
$filter->setTags($request->query->getBoolean('tags', true));
|
||||
$filter->setStorelocation($request->query->getBoolean('storelocation', true));
|
||||
$filter->setComment($request->query->getBoolean('comment', true));
|
||||
$filter->setIPN($request->query->getBoolean('ipn', true));
|
||||
$filter->setOrdernr($request->query->getBoolean('ordernr', true));
|
||||
$filter->setSupplier($request->query->getBoolean('supplier', false));
|
||||
$filter->setManufacturer($request->query->getBoolean('manufacturer', false));
|
||||
$filter->setFootprint($request->query->getBoolean('footprint', false));
|
||||
//As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)!
|
||||
$filter->setName($request->query->getBoolean('name'));
|
||||
$filter->setCategory($request->query->getBoolean('category'));
|
||||
$filter->setDescription($request->query->getBoolean('description'));
|
||||
$filter->setMpn($request->query->getBoolean('mpn'));
|
||||
$filter->setTags($request->query->getBoolean('tags'));
|
||||
$filter->setStorelocation($request->query->getBoolean('storelocation'));
|
||||
$filter->setComment($request->query->getBoolean('comment'));
|
||||
$filter->setIPN($request->query->getBoolean('ipn'));
|
||||
$filter->setOrdernr($request->query->getBoolean('ordernr'));
|
||||
$filter->setSupplier($request->query->getBoolean('supplier'));
|
||||
$filter->setManufacturer($request->query->getBoolean('manufacturer'));
|
||||
$filter->setFootprint($request->query->getBoolean('footprint'));
|
||||
|
||||
|
||||
$filter->setRegex($request->query->getBoolean('regex', false));
|
||||
$filter->setRegex($request->query->getBoolean('regex'));
|
||||
|
||||
return $filter;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ use App\Exceptions\EntityNotSupportedException;
|
||||
use App\Repository\LogEntryRepository;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\LogSystem\LogTargetHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Omines\DataTablesBundle\Column\AbstractColumn;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -43,21 +44,11 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class LogEntryTargetColumn extends AbstractColumn
|
||||
{
|
||||
protected EntityManagerInterface $em;
|
||||
protected LogEntryRepository $entryRepository;
|
||||
protected EntityURLGenerator $entityURLGenerator;
|
||||
protected ElementTypeNameGenerator $elementTypeNameGenerator;
|
||||
protected TranslatorInterface $translator;
|
||||
private LogTargetHelper $logTargetHelper;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager, EntityURLGenerator $entityURLGenerator,
|
||||
ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator)
|
||||
public function __construct(LogTargetHelper $logTargetHelper)
|
||||
{
|
||||
$this->em = $entityManager;
|
||||
$this->entryRepository = $entityManager->getRepository(AbstractLogEntry::class);
|
||||
|
||||
$this->entityURLGenerator = $entityURLGenerator;
|
||||
$this->elementTypeNameGenerator = $elementTypeNameGenerator;
|
||||
$this->translator = $translator;
|
||||
$this->logTargetHelper = $logTargetHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,71 +71,9 @@ class LogEntryTargetColumn extends AbstractColumn
|
||||
|
||||
public function render($value, $context): string
|
||||
{
|
||||
if ($context instanceof UserNotAllowedLogEntry && $this->options['showAccessDeniedPath']) {
|
||||
return htmlspecialchars($context->getPath());
|
||||
}
|
||||
|
||||
/** @var AbstractLogEntry $context */
|
||||
$target = $this->entryRepository->getTargetElement($context);
|
||||
|
||||
$tmp = '';
|
||||
|
||||
//The element is existing
|
||||
if ($target instanceof NamedElementInterface && !empty($target->getName())) {
|
||||
try {
|
||||
$tmp = sprintf(
|
||||
'<a href="%s">%s</a>',
|
||||
$this->entityURLGenerator->infoURL($target),
|
||||
$this->elementTypeNameGenerator->getTypeNameCombination($target, true)
|
||||
);
|
||||
} catch (EntityNotSupportedException $exception) {
|
||||
$tmp = $this->elementTypeNameGenerator->getTypeNameCombination($target, true);
|
||||
}
|
||||
} elseif ($target instanceof AbstractDBElement) { //Target does not have a name
|
||||
$tmp = sprintf(
|
||||
'<i>%s</i>: %s',
|
||||
$this->elementTypeNameGenerator->getLocalizedTypeLabel($target),
|
||||
$target->getID()
|
||||
);
|
||||
} elseif (null === $target && $context->hasTarget()) { //Element was deleted
|
||||
$tmp = sprintf(
|
||||
'<i>%s</i>: %s [%s]',
|
||||
$this->elementTypeNameGenerator->getLocalizedTypeLabel($context->getTargetClass()),
|
||||
$context->getTargetID(),
|
||||
$this->translator->trans('log.target_deleted')
|
||||
);
|
||||
}
|
||||
|
||||
//Add a hint to the associated element if possible
|
||||
if (null !== $target && $this->options['show_associated']) {
|
||||
if ($target instanceof Attachment && null !== $target->getElement()) {
|
||||
$on = $target->getElement();
|
||||
} elseif ($target instanceof AbstractParameter && null !== $target->getElement()) {
|
||||
$on = $target->getElement();
|
||||
} elseif ($target instanceof PartLot && null !== $target->getPart()) {
|
||||
$on = $target->getPart();
|
||||
} elseif ($target instanceof Orderdetail && null !== $target->getPart()) {
|
||||
$on = $target->getPart();
|
||||
} elseif ($target instanceof Pricedetail && null !== $target->getOrderdetail() && null !== $target->getOrderdetail()->getPart()) {
|
||||
$on = $target->getOrderdetail()->getPart();
|
||||
} elseif ($target instanceof ProjectBOMEntry && null !== $target->getProject()) {
|
||||
$on = $target->getProject();
|
||||
}
|
||||
|
||||
if (isset($on) && is_object($on)) {
|
||||
try {
|
||||
$tmp .= sprintf(
|
||||
' (<a href="%s">%s</a>)',
|
||||
$this->entityURLGenerator->infoURL($on),
|
||||
$this->elementTypeNameGenerator->getTypeNameCombination($on, true)
|
||||
);
|
||||
} catch (EntityNotSupportedException $exception) {
|
||||
$tmp .= ' ('.$this->elementTypeNameGenerator->getTypeNameCombination($target, true).')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Log is not associated with an element
|
||||
return $tmp;
|
||||
return $this->logTargetHelper->formatTarget($context, [
|
||||
'showAccessDeniedPath' => $this->options['showAccessDeniedPath'],
|
||||
'show_associated' => $this->options['show_associated'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,13 +73,13 @@ class RevertLogColumn extends AbstractColumn
|
||||
{
|
||||
if (
|
||||
$context instanceof CollectionElementDeleted
|
||||
|| ($context instanceof ElementDeletedLogEntry && $context->hasOldDataInformations())
|
||||
|| ($context instanceof ElementDeletedLogEntry && $context->hasOldDataInformation())
|
||||
) {
|
||||
$icon = 'fa-trash-restore';
|
||||
$title = $this->translator->trans('log.undo.undelete');
|
||||
} elseif (
|
||||
$context instanceof ElementCreatedLogEntry
|
||||
|| ($context instanceof ElementEditedLogEntry && $context->hasOldDataInformations())
|
||||
|| ($context instanceof ElementEditedLogEntry && $context->hasOldDataInformation())
|
||||
) {
|
||||
$icon = 'fa-undo';
|
||||
$title = $this->translator->trans('log.undo.undo');
|
||||
|
||||
85
src/DataTables/ErrorDataTable.php
Normal file
85
src/DataTables/ErrorDataTable.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\DataTables;
|
||||
|
||||
use App\DataTables\Column\RowClassColumn;
|
||||
use App\Entity\Parts\Part;
|
||||
use Omines\DataTablesBundle\Adapter\ArrayAdapter;
|
||||
use Omines\DataTablesBundle\Column\TextColumn;
|
||||
use Omines\DataTablesBundle\DataTable;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Omines\DataTablesBundle\DataTableTypeInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ErrorDataTable implements DataTableTypeInterface
|
||||
{
|
||||
public function configureOptions(OptionsResolver $optionsResolver): void
|
||||
{
|
||||
$optionsResolver->setRequired('errors');
|
||||
$optionsResolver->setAllowedTypes('errors', ['array', 'string']);
|
||||
$optionsResolver->setNormalizer('errors', function (OptionsResolver $optionsResolver, $errors) {
|
||||
if (is_string($errors)) {
|
||||
$errors = [$errors];
|
||||
}
|
||||
|
||||
return $errors;
|
||||
});
|
||||
}
|
||||
|
||||
public function configure(DataTable $dataTable, array $options)
|
||||
{
|
||||
$optionsResolver = new OptionsResolver();
|
||||
$this->configureOptions($optionsResolver);
|
||||
$options = $optionsResolver->resolve($options);
|
||||
|
||||
$dataTable
|
||||
->add('dont_matter_we_only_set_color', RowClassColumn::class, [
|
||||
'render' => function ($value, $context) {
|
||||
return 'table-warning';
|
||||
},
|
||||
])
|
||||
|
||||
->add('error', TextColumn::class, [
|
||||
'label' => 'error_table.error',
|
||||
'render' => function ($value, $context) {
|
||||
return '<i class="fa-solid fa-triangle-exclamation fa-fw"></i> ' . $value;
|
||||
},
|
||||
])
|
||||
;
|
||||
|
||||
//Build the array containing data
|
||||
$data = [];
|
||||
foreach ($options['errors'] as $error) {
|
||||
$data[] = ['error' => $error];
|
||||
}
|
||||
|
||||
$dataTable->createAdapter(ArrayAdapter::class, $data);
|
||||
}
|
||||
|
||||
public static function errorTable(DataTableFactory $dataTableFactory, Request $request, $errors): Response
|
||||
{
|
||||
$error_table = $dataTableFactory->createFromType(self::class, ['errors' => $errors]);
|
||||
$error_table->handleRequest($request);
|
||||
return $error_table->getResponse();
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ use App\Exceptions\EntityNotSupportedException;
|
||||
use App\Repository\LogEntryRepository;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\LogSystem\LogLevelHelper;
|
||||
use App\Services\UserSystem\UserAvatarHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
@@ -67,10 +68,11 @@ class LogDataTable implements DataTableTypeInterface
|
||||
protected LogEntryRepository $logRepo;
|
||||
protected Security $security;
|
||||
protected UserAvatarHelper $userAvatarHelper;
|
||||
protected LogLevelHelper $logLevelHelper;
|
||||
|
||||
public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator,
|
||||
UrlGeneratorInterface $urlGenerator, EntityURLGenerator $entityURLGenerator, EntityManagerInterface $entityManager,
|
||||
Security $security, UserAvatarHelper $userAvatarHelper)
|
||||
Security $security, UserAvatarHelper $userAvatarHelper, LogLevelHelper $logLevelHelper)
|
||||
{
|
||||
$this->elementTypeNameGenerator = $elementTypeNameGenerator;
|
||||
$this->translator = $translator;
|
||||
@@ -79,6 +81,7 @@ class LogDataTable implements DataTableTypeInterface
|
||||
$this->logRepo = $entityManager->getRepository(AbstractLogEntry::class);
|
||||
$this->security = $security;
|
||||
$this->userAvatarHelper = $userAvatarHelper;
|
||||
$this->logLevelHelper = $logLevelHelper;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $optionsResolver): void
|
||||
@@ -112,69 +115,18 @@ class LogDataTable implements DataTableTypeInterface
|
||||
|
||||
//This special $$rowClass column is used to set the row class depending on the log level. The class gets set by the frontend controller
|
||||
$dataTable->add('dont_matter', RowClassColumn::class, [
|
||||
'render' => static function ($value, AbstractLogEntry $context) {
|
||||
switch ($context->getLevel()) {
|
||||
case AbstractLogEntry::LEVEL_EMERGENCY:
|
||||
case AbstractLogEntry::LEVEL_ALERT:
|
||||
case AbstractLogEntry::LEVEL_CRITICAL:
|
||||
case AbstractLogEntry::LEVEL_ERROR:
|
||||
return 'table-danger';
|
||||
case AbstractLogEntry::LEVEL_WARNING:
|
||||
return 'table-warning';
|
||||
case AbstractLogEntry::LEVEL_NOTICE:
|
||||
return 'table-info';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
'render' => function ($value, AbstractLogEntry $context) {
|
||||
return $this->logLevelHelper->logLevelToTableColorClass($context->getLevelString());
|
||||
},
|
||||
]);
|
||||
|
||||
$dataTable->add('symbol', TextColumn::class, [
|
||||
'label' => '',
|
||||
'className' => 'no-colvis',
|
||||
'render' => static function ($value, AbstractLogEntry $context) {
|
||||
switch ($context->getLevelString()) {
|
||||
case LogLevel::DEBUG:
|
||||
$symbol = 'fa-bug';
|
||||
|
||||
break;
|
||||
case LogLevel::INFO:
|
||||
$symbol = 'fa-info';
|
||||
|
||||
break;
|
||||
case LogLevel::NOTICE:
|
||||
$symbol = 'fa-flag';
|
||||
|
||||
break;
|
||||
case LogLevel::WARNING:
|
||||
$symbol = 'fa-exclamation-circle';
|
||||
|
||||
break;
|
||||
case LogLevel::ERROR:
|
||||
$symbol = 'fa-exclamation-triangle';
|
||||
|
||||
break;
|
||||
case LogLevel::CRITICAL:
|
||||
$symbol = 'fa-bolt';
|
||||
|
||||
break;
|
||||
case LogLevel::ALERT:
|
||||
$symbol = 'fa-radiation';
|
||||
|
||||
break;
|
||||
case LogLevel::EMERGENCY:
|
||||
$symbol = 'fa-skull-crossbones';
|
||||
|
||||
break;
|
||||
default:
|
||||
$symbol = 'fa-question-circle';
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
'render' => function ($value, AbstractLogEntry $context) {
|
||||
return sprintf(
|
||||
'<i class="fas fa-fw %s" title="%s"></i>',
|
||||
$symbol,
|
||||
$this->logLevelHelper->logLevelToIconClass($context->getLevelString()),
|
||||
$context->getLevelString()
|
||||
);
|
||||
},
|
||||
@@ -188,6 +140,12 @@ class LogDataTable implements DataTableTypeInterface
|
||||
$dataTable->add('timestamp', LocaleDateTimeColumn::class, [
|
||||
'label' => 'log.timestamp',
|
||||
'timeFormat' => 'medium',
|
||||
'render' => function (string $value, AbstractLogEntry $context) {
|
||||
return sprintf('<a href="%s">%s</a>',
|
||||
$this->urlGenerator->generate('log_details', ['id' => $context->getId()]),
|
||||
$value
|
||||
);
|
||||
}
|
||||
]);
|
||||
|
||||
$dataTable->add('type', TextColumn::class, [
|
||||
@@ -278,7 +236,7 @@ class LogDataTable implements DataTableTypeInterface
|
||||
'href' => function ($value, AbstractLogEntry $context) {
|
||||
if (
|
||||
($context instanceof TimeTravelInterface
|
||||
&& $context->hasOldDataInformations())
|
||||
&& $context->hasOldDataInformation())
|
||||
|| $context instanceof CollectionElementDeleted
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use App\Exceptions\InvalidRegexException;
|
||||
use Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface;
|
||||
use Doctrine\DBAL\Event\ConnectionEventArgs;
|
||||
use Doctrine\DBAL\Events;
|
||||
@@ -43,7 +44,11 @@ class SQLiteRegexExtension implements EventSubscriberInterface
|
||||
//Ensure that the function really exists on the connection, as it is marked as experimental according to PHP documentation
|
||||
if($native_connection instanceof \PDO && method_exists($native_connection, 'sqliteCreateFunction' )) {
|
||||
$native_connection->sqliteCreateFunction('REGEXP', function ($pattern, $value) {
|
||||
return (false !== mb_ereg($pattern, $value)) ? 1 : 0;
|
||||
try {
|
||||
return (false !== mb_ereg($pattern, $value)) ? 1 : 0;
|
||||
} catch (\ErrorException $e) {
|
||||
throw InvalidRegexException::fromMBRegexError($e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
41
src/Entity/Contracts/LogWithNewDataInterface.php
Normal file
41
src/Entity/Contracts/LogWithNewDataInterface.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Entity\Contracts;
|
||||
|
||||
interface LogWithNewDataInterface
|
||||
{
|
||||
/**
|
||||
* Checks if this entry has information about the new data.
|
||||
* @return bool
|
||||
*/
|
||||
public function hasNewDataInformation(): bool;
|
||||
|
||||
/**
|
||||
* Returns the new data for this entry.
|
||||
*/
|
||||
public function getNewData(): array;
|
||||
|
||||
/**
|
||||
* Sets the new data for this entry.
|
||||
* @return $this
|
||||
*/
|
||||
public function setNewData(array $new_data): self;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ interface TimeTravelInterface
|
||||
*
|
||||
* @return bool true if this entry has information about the changed data
|
||||
*/
|
||||
public function hasOldDataInformations(): bool;
|
||||
public function hasOldDataInformation(): bool;
|
||||
|
||||
/**
|
||||
* Returns the data the entity had before this log entry.
|
||||
|
||||
@@ -88,7 +88,7 @@ class ElementDeletedLogEntry extends AbstractLogEntry implements TimeTravelInter
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasOldDataInformations(): bool
|
||||
public function hasOldDataInformation(): bool
|
||||
{
|
||||
return !empty($this->extra['o']);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace App\Entity\LogSystem;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Contracts\LogWithCommentInterface;
|
||||
use App\Entity\Contracts\LogWithEventUndoInterface;
|
||||
use App\Entity\Contracts\LogWithNewDataInterface;
|
||||
use App\Entity\Contracts\TimeTravelInterface;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use InvalidArgumentException;
|
||||
@@ -32,7 +33,7 @@ use InvalidArgumentException;
|
||||
/**
|
||||
* @ORM\Entity()
|
||||
*/
|
||||
class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterface, LogWithCommentInterface, LogWithEventUndoInterface
|
||||
class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterface, LogWithCommentInterface, LogWithEventUndoInterface, LogWithNewDataInterface
|
||||
{
|
||||
protected string $typeString = 'element_edited';
|
||||
|
||||
@@ -49,7 +50,7 @@ class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterf
|
||||
*/
|
||||
public function hasChangedFieldsInfo(): bool
|
||||
{
|
||||
return isset($this->extra['f']) || $this->hasOldDataInformations();
|
||||
return isset($this->extra['f']) || $this->hasOldDataInformation();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +60,7 @@ class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterf
|
||||
*/
|
||||
public function getChangedFields(): array
|
||||
{
|
||||
if ($this->hasOldDataInformations()) {
|
||||
if ($this->hasOldDataInformation()) {
|
||||
return array_keys($this->getOldData());
|
||||
}
|
||||
|
||||
@@ -92,7 +93,29 @@ class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterf
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasOldDataInformations(): bool
|
||||
public function hasNewDataInformation(): bool
|
||||
{
|
||||
return !empty($this->extra['n']);
|
||||
}
|
||||
|
||||
public function getNewData(): array
|
||||
{
|
||||
return $this->extra['n'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the old data for this entry.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setNewData(array $new_data): self
|
||||
{
|
||||
$this->extra['n'] = $new_data;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasOldDataInformation(): bool
|
||||
{
|
||||
return !empty($this->extra['d']);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ class SecurityEventLogEntry extends AbstractLogEntry
|
||||
{
|
||||
$key = $this->extra['e'];
|
||||
|
||||
return static::SECURITY_TYPE_MAPPING[$key] ?? 'unkown';
|
||||
return static::SECURITY_TYPE_MAPPING[$key] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,11 +78,12 @@ class EventLoggerSubscriber implements EventSubscriber
|
||||
protected bool $save_changed_fields;
|
||||
protected bool $save_changed_data;
|
||||
protected bool $save_removed_data;
|
||||
protected bool $save_new_data;
|
||||
protected PropertyAccessorInterface $propertyAccessor;
|
||||
|
||||
public function __construct(EventLogger $logger, SerializerInterface $serializer, EventCommentHelper $commentHelper,
|
||||
bool $save_changed_fields, bool $save_changed_data, bool $save_removed_data, PropertyAccessorInterface $propertyAccessor,
|
||||
EventUndoHelper $eventUndoHelper)
|
||||
bool $save_changed_fields, bool $save_changed_data, bool $save_removed_data, bool $save_new_data,
|
||||
PropertyAccessorInterface $propertyAccessor, EventUndoHelper $eventUndoHelper)
|
||||
{
|
||||
$this->logger = $logger;
|
||||
$this->serializer = $serializer;
|
||||
@@ -93,6 +94,8 @@ class EventLoggerSubscriber implements EventSubscriber
|
||||
$this->save_changed_fields = $save_changed_fields;
|
||||
$this->save_changed_data = $save_changed_data;
|
||||
$this->save_removed_data = $save_removed_data;
|
||||
//This option only makes sense if save_changed_data is true
|
||||
$this->save_new_data = $save_new_data && $save_changed_data;
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $eventArgs): void
|
||||
@@ -150,6 +153,7 @@ class EventLoggerSubscriber implements EventSubscriber
|
||||
$log->setTargetElementID($undoEvent->getDeletedElementID());
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->log($log);
|
||||
}
|
||||
}
|
||||
@@ -301,6 +305,24 @@ class EventLoggerSubscriber implements EventSubscriber
|
||||
}, ARRAY_FILTER_USE_BOTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the length of every string in the given array to MAX_STRING_LENGTH, to save memory in the case of very
|
||||
* long strings (e.g. images in notes)
|
||||
* @param array $fields
|
||||
* @return array
|
||||
*/
|
||||
protected function fieldLengthRestrict(array $fields): array
|
||||
{
|
||||
return array_map(
|
||||
static function ($value) {
|
||||
if (is_string($value)) {
|
||||
return mb_strimwidth($value, 0, self::MAX_STRING_LENGTH, '...');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}, $fields);
|
||||
}
|
||||
|
||||
protected function saveChangeSet(AbstractDBElement $entity, AbstractLogEntry $logEntry, EntityManagerInterface $em, bool $element_deleted = false): void
|
||||
{
|
||||
$uow = $em->getUnitOfWork();
|
||||
@@ -314,20 +336,24 @@ class EventLoggerSubscriber implements EventSubscriber
|
||||
} else { //Otherwise we have to get it from entity changeset
|
||||
$changeSet = $uow->getEntityChangeSet($entity);
|
||||
$old_data = array_combine(array_keys($changeSet), array_column($changeSet, 0));
|
||||
//If save_new_data is enabled, we extract it from the change set
|
||||
if ($this->save_new_data) {
|
||||
$new_data = array_combine(array_keys($changeSet), array_column($changeSet, 1));
|
||||
}
|
||||
}
|
||||
$old_data = $this->filterFieldRestrictions($entity, $old_data);
|
||||
|
||||
//Restrict length of string fields, to save memory...
|
||||
$old_data = array_map(
|
||||
static function ($value) {
|
||||
if (is_string($value)) {
|
||||
return mb_strimwidth($value, 0, self::MAX_STRING_LENGTH, '...');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}, $old_data);
|
||||
$old_data = $this->fieldLengthRestrict($old_data);
|
||||
|
||||
$logEntry->setOldData($old_data);
|
||||
|
||||
if (!empty($new_data)) {
|
||||
$new_data = $this->filterFieldRestrictions($entity, $new_data);
|
||||
$new_data = $this->fieldLengthRestrict($new_data);
|
||||
|
||||
$logEntry->setNewData($new_data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
80
src/Exceptions/InvalidRegexException.php
Normal file
80
src/Exceptions/InvalidRegexException.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Doctrine\DBAL\Exception\DriverException;
|
||||
use ErrorException;
|
||||
|
||||
class InvalidRegexException extends \RuntimeException
|
||||
{
|
||||
private ?string $reason;
|
||||
|
||||
public function __construct(string $reason = null)
|
||||
{
|
||||
$this->reason = $reason;
|
||||
parent::__construct('Invalid regular expression');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the reason for the exception (what the regex driver deemed invalid)
|
||||
* @return string|null
|
||||
*/
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new exception from a driver exception happening, when MySQL encounters an invalid regex
|
||||
* @param DriverException $exception
|
||||
* @return self
|
||||
*/
|
||||
public static function fromDriverException(DriverException $exception): self
|
||||
{
|
||||
//1139 means invalid regex error
|
||||
if ($exception->getCode() !== 1139) {
|
||||
throw new \InvalidArgumentException('The given exception is not a driver exception', 0, $exception);
|
||||
}
|
||||
|
||||
//Reason is the part after the erorr code
|
||||
$reason = preg_replace('/^.*1139 /', '', $exception->getMessage());
|
||||
|
||||
return new self($reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new exception from the errorException thrown by mb_ereg
|
||||
* @param ErrorException $ex
|
||||
* @return self
|
||||
*/
|
||||
public static function fromMBRegexError(ErrorException $ex): self
|
||||
{
|
||||
//Ensure that the error is really a mb_ereg error
|
||||
if ($ex->getSeverity() !== E_WARNING || !strpos($ex->getMessage(), 'mb_ereg()') !== false) {
|
||||
throw new \InvalidArgumentException('The given exception is not a mb_ereg error', 0, $ex);
|
||||
}
|
||||
|
||||
//Reason is the part after the erorr code
|
||||
$reason = preg_replace('/^.*mb_ereg\(\): /', '', $ex->getMessage());
|
||||
|
||||
return new self($reason);
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,22 @@ declare(strict_types=1);
|
||||
namespace App\Security\Voter;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentTypeAttachment;
|
||||
use App\Entity\Attachments\CategoryAttachment;
|
||||
use App\Entity\Attachments\CurrencyAttachment;
|
||||
use App\Entity\Attachments\FootprintAttachment;
|
||||
use App\Entity\Attachments\GroupAttachment;
|
||||
use App\Entity\Attachments\ManufacturerAttachment;
|
||||
use App\Entity\Attachments\MeasurementUnitAttachment;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\Attachments\ProjectAttachment;
|
||||
use App\Entity\Attachments\StorelocationAttachment;
|
||||
use App\Entity\Attachments\SupplierAttachment;
|
||||
use App\Entity\Attachments\UserAttachment;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\UserSystem\PermissionManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
use function in_array;
|
||||
@@ -50,26 +63,78 @@ class AttachmentVoter extends ExtendedVoter
|
||||
{
|
||||
//return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
|
||||
|
||||
//If the attachment has no element (which should not happen), we deny access, as we can not determine if the user is allowed to access the associated element
|
||||
$target_element = $subject->getElement();
|
||||
if (! $subject instanceof Attachment || null === $target_element) {
|
||||
//This voter only works for attachments
|
||||
if (!is_a($subject, Attachment::class, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//Depending on the operation delegate either to the attachments element or to the attachment permission
|
||||
if ($attribute === 'show_private') {
|
||||
return $this->resolver->inherit($user, 'attachments', 'show_private') ?? false;
|
||||
}
|
||||
|
||||
|
||||
if (is_object($subject)) {
|
||||
//If the attachment has no element (which should not happen), we deny access, as we can not determine if the user is allowed to access the associated element
|
||||
$target_element = $subject->getElement();
|
||||
if ($target_element) {
|
||||
return $this->security->isGranted($this->mapOperation($attribute), $target_element);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($subject)) {
|
||||
//If we do not have a concrete element (or we just got a string as value), we delegate to the different categories
|
||||
if (is_a($subject, AttachmentTypeAttachment::class, true)) {
|
||||
$param = 'attachment_types';
|
||||
} elseif (is_a($subject, CategoryAttachment::class, true)) {
|
||||
$param = 'categories';
|
||||
} elseif (is_a($subject, CurrencyAttachment::class, true)) {
|
||||
$param = 'currencies';
|
||||
} elseif (is_a($subject, ProjectAttachment::class, true)) {
|
||||
$param = 'projects';
|
||||
} elseif (is_a($subject, FootprintAttachment::class, true)) {
|
||||
$param = 'footprints';
|
||||
} elseif (is_a($subject, GroupAttachment::class, true)) {
|
||||
$param = 'groups';
|
||||
} elseif (is_a($subject, ManufacturerAttachment::class, true)) {
|
||||
$param = 'manufacturers';
|
||||
} elseif (is_a($subject, MeasurementUnitAttachment::class, true)) {
|
||||
$param = 'measurement_units';
|
||||
} elseif (is_a($subject, PartAttachment::class, true)) {
|
||||
$param = 'parts';
|
||||
} elseif (is_a($subject, StorelocationAttachment::class, true)) {
|
||||
$param = 'storelocations';
|
||||
} elseif (is_a($subject, SupplierAttachment::class, true)) {
|
||||
$param = 'suppliers';
|
||||
} elseif (is_a($subject, UserAttachment::class, true)) {
|
||||
$param = 'users';
|
||||
} elseif ($subject === Attachment::class) {
|
||||
//If the subject was deleted, we can not determine the type properly, so we just use the parts permission
|
||||
$param = 'parts';
|
||||
}
|
||||
else {
|
||||
throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? get_class($subject) : $subject));
|
||||
}
|
||||
|
||||
return $this->resolver->inherit($user, $param, $this->mapOperation($attribute)) ?? false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function mapOperation(string $attribute): string
|
||||
{
|
||||
switch ($attribute) {
|
||||
//We can view the attachment if we can view the element
|
||||
case 'read':
|
||||
case 'view':
|
||||
return $this->security->isGranted('read', $target_element);
|
||||
return 'read';
|
||||
//We can edit/create/delete the attachment if we can edit the element
|
||||
case 'edit':
|
||||
case 'create':
|
||||
case 'delete':
|
||||
return $this->security->isGranted('edit', $target_element);
|
||||
|
||||
case 'show_private':
|
||||
return $this->resolver->inherit($user, 'attachments', 'show_private') ?? false;
|
||||
return 'edit';
|
||||
case 'show_history':
|
||||
return 'show_history';
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Encountered unknown attribute "'.$attribute.'" in AttachmentVoter!');
|
||||
@@ -87,7 +152,7 @@ class AttachmentVoter extends ExtendedVoter
|
||||
{
|
||||
if (is_a($subject, Attachment::class, true)) {
|
||||
//These are the allowed attributes
|
||||
return in_array($attribute, ['read', 'view', 'edit', 'delete', 'create', 'show_private'], true);
|
||||
return in_array($attribute, ['read', 'view', 'edit', 'delete', 'create', 'show_private', 'show_history'], true);
|
||||
}
|
||||
|
||||
//Allow class name as subject
|
||||
|
||||
@@ -24,13 +24,28 @@ namespace App\Security\Voter;
|
||||
|
||||
use App\Entity\LogSystem\AbstractLogEntry;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\UserSystem\PermissionManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
class LogEntryVoter extends ExtendedVoter
|
||||
{
|
||||
public const ALLOWED_OPS = ['read', 'delete'];
|
||||
public const ALLOWED_OPS = ['read', 'show_details', 'delete'];
|
||||
|
||||
private Security $security;
|
||||
|
||||
public function __construct(PermissionManager $resolver, EntityManagerInterface $entityManager, Security $security)
|
||||
{
|
||||
parent::__construct($resolver, $entityManager);
|
||||
$this->security = $security;
|
||||
}
|
||||
|
||||
protected function voteOnUser(string $attribute, $subject, User $user): bool
|
||||
{
|
||||
if (!$subject instanceof AbstractLogEntry) {
|
||||
throw new \InvalidArgumentException('The subject must be an instance of '.AbstractLogEntry::class);
|
||||
}
|
||||
|
||||
if ('delete' === $attribute) {
|
||||
return $this->resolver->inherit($user, 'system', 'delete_logs') ?? false;
|
||||
}
|
||||
@@ -47,13 +62,24 @@ class LogEntryVoter extends ExtendedVoter
|
||||
return $this->resolver->inherit($user, 'system', 'show_logs') ?? false;
|
||||
}
|
||||
|
||||
if ('show_details' === $attribute) {
|
||||
//To view details of a element related log entry, the user needs to be able to view the history of this entity type
|
||||
$targetClass = $subject->getTargetClass();
|
||||
if (null !== $targetClass) {
|
||||
return $this->security->isGranted('show_history', $targetClass) ?? false;
|
||||
}
|
||||
|
||||
//In other cases, this behaves like the read permission
|
||||
return $this->voteOnUser('read', $subject, $user);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function supports($attribute, $subject): bool
|
||||
{
|
||||
if ($subject instanceof AbstractLogEntry) {
|
||||
return in_array($subject, static::ALLOWED_OPS, true);
|
||||
return in_array($attribute, static::ALLOWED_OPS, true);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -53,66 +53,72 @@ class ParameterVoter extends ExtendedVoter
|
||||
{
|
||||
//return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
|
||||
|
||||
if (!$subject instanceof AbstractParameter) {
|
||||
if (!is_a($subject, AbstractParameter::class, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//If the attachment has no element (which should not happen), we deny access, as we can not determine if the user is allowed to access the associated element
|
||||
$target_element = $subject->getElement();
|
||||
if ($target_element !== null) {
|
||||
//Depending on the operation delegate either to the attachments element or to the attachment permission
|
||||
if (is_object($subject)) {
|
||||
//If the attachment has no element (which should not happen), we deny access, as we can not determine if the user is allowed to access the associated element
|
||||
$target_element = $subject->getElement();
|
||||
if ($target_element !== null) {
|
||||
//Depending on the operation delegate either to the attachments element or to the attachment permission
|
||||
|
||||
|
||||
switch ($attribute) {
|
||||
//We can view the attachment if we can view the element
|
||||
case 'read':
|
||||
case 'view':
|
||||
$operation = 'read';
|
||||
break;
|
||||
//We can edit/create/delete the attachment if we can edit the element
|
||||
case 'edit':
|
||||
case 'create':
|
||||
case 'delete':
|
||||
$operation = 'edit';
|
||||
break;
|
||||
case 'show_history':
|
||||
$operation = 'show_history';
|
||||
break;
|
||||
case 'revert_element':
|
||||
$operation = 'revert_element';
|
||||
break;
|
||||
default:
|
||||
throw new RuntimeException('Unknown operation: '.$attribute);
|
||||
switch ($attribute) {
|
||||
//We can view the attachment if we can view the element
|
||||
case 'read':
|
||||
case 'view':
|
||||
$operation = 'read';
|
||||
break;
|
||||
//We can edit/create/delete the attachment if we can edit the element
|
||||
case 'edit':
|
||||
case 'create':
|
||||
case 'delete':
|
||||
$operation = 'edit';
|
||||
break;
|
||||
case 'show_history':
|
||||
$operation = 'show_history';
|
||||
break;
|
||||
case 'revert_element':
|
||||
$operation = 'revert_element';
|
||||
break;
|
||||
default:
|
||||
throw new RuntimeException('Unknown operation: '.$attribute);
|
||||
}
|
||||
|
||||
return $this->security->isGranted($operation, $target_element);
|
||||
}
|
||||
|
||||
return $this->security->isGranted($operation, $target_element);
|
||||
}
|
||||
|
||||
//If we do not have a concrete element, we delegate to the different categories
|
||||
if ($subject instanceof AttachmentTypeParameter) {
|
||||
//If we do not have a concrete element (or we just got a string as value), we delegate to the different categories
|
||||
if (is_a($subject, AttachmentTypeParameter::class, true)) {
|
||||
$param = 'attachment_types';
|
||||
} elseif ($subject instanceof CategoryParameter) {
|
||||
} elseif (is_a($subject, CategoryParameter::class, true)) {
|
||||
$param = 'categories';
|
||||
} elseif ($subject instanceof CurrencyParameter) {
|
||||
} elseif (is_a($subject, CurrencyParameter::class, true)) {
|
||||
$param = 'currencies';
|
||||
} elseif ($subject instanceof ProjectParameter) {
|
||||
} elseif (is_a($subject, ProjectParameter::class, true)) {
|
||||
$param = 'projects';
|
||||
} elseif ($subject instanceof FootprintParameter) {
|
||||
} elseif (is_a($subject, FootprintParameter::class, true)) {
|
||||
$param = 'footprints';
|
||||
} elseif ($subject instanceof GroupParameter) {
|
||||
} elseif (is_a($subject, GroupParameter::class, true)) {
|
||||
$param = 'groups';
|
||||
} elseif ($subject instanceof ManufacturerParameter) {
|
||||
} elseif (is_a($subject, ManufacturerParameter::class, true)) {
|
||||
$param = 'manufacturers';
|
||||
} elseif ($subject instanceof MeasurementUnitParameter) {
|
||||
} elseif (is_a($subject, MeasurementUnitParameter::class, true)) {
|
||||
$param = 'measurement_units';
|
||||
} elseif ($subject instanceof PartParameter) {
|
||||
} elseif (is_a($subject, PartParameter::class, true)) {
|
||||
$param = 'parts';
|
||||
} elseif ($subject instanceof StorelocationParameter) {
|
||||
} elseif (is_a($subject, StorelocationParameter::class, true)) {
|
||||
$param = 'storelocations';
|
||||
} elseif ($subject instanceof SupplierParameter) {
|
||||
} elseif (is_a($subject, SupplierParameter::class, true)) {
|
||||
$param = 'suppliers';
|
||||
} else {
|
||||
throw new RuntimeException('Encountered unknown Parameter type: ' . get_class($subject));
|
||||
} elseif ($subject === AbstractParameter::class) {
|
||||
//If the subject was deleted, we can not determine the type properly, so we just use the parts permission
|
||||
$param = 'parts';
|
||||
}
|
||||
else {
|
||||
throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? get_class($subject) : $subject));
|
||||
}
|
||||
|
||||
return $this->resolver->inherit($user, $param, $attribute) ?? false;
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace App\Services;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Contracts\NamedElementInterface;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\LabelSystem\LabelProfile;
|
||||
@@ -43,17 +44,20 @@ use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Entity\UserSystem\Group;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Exceptions\EntityNotSupportedException;
|
||||
use Doctrine\ORM\Mapping\Entity;
|
||||
use function get_class;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class ElementTypeNameGenerator
|
||||
{
|
||||
protected TranslatorInterface $translator;
|
||||
private EntityURLGenerator $entityURLGenerator;
|
||||
protected array $mapping;
|
||||
|
||||
public function __construct(TranslatorInterface $translator)
|
||||
public function __construct(TranslatorInterface $translator, EntityURLGenerator $entityURLGenerator)
|
||||
{
|
||||
$this->translator = $translator;
|
||||
$this->entityURLGenerator = $entityURLGenerator;
|
||||
|
||||
//Child classes has to become before parent classes
|
||||
$this->mapping = [
|
||||
@@ -132,4 +136,81 @@ class ElementTypeNameGenerator
|
||||
|
||||
return $type.': '.$entity->getName();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a HTML formatted label for the given enitity in the format "Type: Name" (on elements with a name) and
|
||||
* "Type: ID" (on elements without a name). If possible the value is given as a link to the element.
|
||||
* @param AbstractDBElement $entity The entity for which the label should be generated
|
||||
* @param bool $include_associated If set to true, the associated entity (like the part belonging to a part lot) is included in the label to give further information
|
||||
* @return string
|
||||
*/
|
||||
public function formatLabelHTMLForEntity(AbstractDBElement $entity, bool $include_associated = false): string
|
||||
{
|
||||
//The element is existing
|
||||
if ($entity instanceof NamedElementInterface && !empty($entity->getName())) {
|
||||
try {
|
||||
$tmp = sprintf(
|
||||
'<a href="%s">%s</a>',
|
||||
$this->entityURLGenerator->infoURL($entity),
|
||||
$this->getTypeNameCombination($entity, true)
|
||||
);
|
||||
} catch (EntityNotSupportedException $exception) {
|
||||
$tmp = $this->getTypeNameCombination($entity, true);
|
||||
}
|
||||
} else { //Target does not have a name
|
||||
$tmp = sprintf(
|
||||
'<i>%s</i>: %s',
|
||||
$this->getLocalizedTypeLabel($entity),
|
||||
$entity->getID()
|
||||
);
|
||||
}
|
||||
|
||||
//Add a hint to the associated element if possible
|
||||
if ($include_associated) {
|
||||
if ($entity instanceof Attachment && null !== $entity->getElement()) {
|
||||
$on = $entity->getElement();
|
||||
} elseif ($entity instanceof AbstractParameter && null !== $entity->getElement()) {
|
||||
$on = $entity->getElement();
|
||||
} elseif ($entity instanceof PartLot && null !== $entity->getPart()) {
|
||||
$on = $entity->getPart();
|
||||
} elseif ($entity instanceof Orderdetail && null !== $entity->getPart()) {
|
||||
$on = $entity->getPart();
|
||||
} elseif ($entity instanceof Pricedetail && null !== $entity->getOrderdetail() && null !== $entity->getOrderdetail()->getPart()) {
|
||||
$on = $entity->getOrderdetail()->getPart();
|
||||
} elseif ($entity instanceof ProjectBOMEntry && null !== $entity->getProject()) {
|
||||
$on = $entity->getProject();
|
||||
}
|
||||
|
||||
if (isset($on) && is_object($on)) {
|
||||
try {
|
||||
$tmp .= sprintf(
|
||||
' (<a href="%s">%s</a>)',
|
||||
$this->entityURLGenerator->infoURL($on),
|
||||
$this->getTypeNameCombination($on, true)
|
||||
);
|
||||
} catch (EntityNotSupportedException $exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a HTML formatted label for a deleted element of which we only know the class and the ID.
|
||||
* Please note that it is not checked if the element really not exists anymore, so you have to do this yourself.
|
||||
* @param string $class
|
||||
* @param int $id
|
||||
* @return string
|
||||
*/
|
||||
public function formatElementDeletedHTML(string $class, int $id): string
|
||||
{
|
||||
return sprintf(
|
||||
'<i>%s</i>: %s [%s]',
|
||||
$this->getLocalizedTypeLabel($class),
|
||||
$id,
|
||||
$this->translator->trans('log.target_deleted')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Parameters\PartParameter;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\LabelSystem\LabelProfile;
|
||||
use App\Entity\Parts\Category;
|
||||
@@ -106,7 +107,7 @@ class EntityURLGenerator
|
||||
/**
|
||||
* Gets the URL to view the given element at a given timestamp.
|
||||
*/
|
||||
public function timeTravelURL(AbstractDBElement $entity, DateTime $dateTime): string
|
||||
public function timeTravelURL(AbstractDBElement $entity, \DateTimeInterface $dateTime): string
|
||||
{
|
||||
$map = [
|
||||
Part::class => 'part_info',
|
||||
@@ -158,10 +159,16 @@ class EntityURLGenerator
|
||||
'timestamp' => $dateTime->getTimestamp(),
|
||||
]);
|
||||
}
|
||||
if ($entity instanceof PartParameter) {
|
||||
return $this->urlGenerator->generate('part_info', [
|
||||
'id' => $entity->getElement()->getID(),
|
||||
'timestamp' => $dateTime->getTimestamp(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
//Otherwise throw an error
|
||||
throw new EntityNotSupportedException('The given entity is not supported yet!');
|
||||
throw new EntityNotSupportedException('The given entity is not supported yet! Passed class type: '.get_class($entity));
|
||||
}
|
||||
|
||||
public function viewURL(Attachment $entity): string
|
||||
|
||||
@@ -84,7 +84,15 @@ trait PKImportHelperTrait
|
||||
|
||||
//Determine file extension (if the extension is empty, we use the original extension)
|
||||
if (empty($attachment_row['extension'])) {
|
||||
$attachment_row['extension'] = pathinfo($attachment_row['originalname'], PATHINFO_EXTENSION);
|
||||
//Use mime type to determine the extension like PartKeepr does in legacy implementation (just use the second part of the mime type)
|
||||
//See UploadedFile.php:291 in PartKeepr (https://github.com/partkeepr/PartKeepr/blob/f6176c3354b24fa39ac8bc4328ee0df91de3d5b6/src/PartKeepr/UploadedFileBundle/Entity/UploadedFile.php#L291)
|
||||
if (!empty ($attachment_row['mimetype'])) {
|
||||
$attachment_row['extension'] = explode('/', $attachment_row['mimetype'])[1];
|
||||
} else {
|
||||
//If the mime type is empty, we use the original extension
|
||||
$attachment_row['extension'] = pathinfo($attachment_row['originalname'], PATHINFO_EXTENSION);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//Determine file path
|
||||
|
||||
@@ -30,10 +30,12 @@ use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\Parts\Storelocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use App\Entity\PriceInformations\Pricedetail;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Intl\Currencies;
|
||||
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
|
||||
|
||||
/**
|
||||
@@ -43,10 +45,13 @@ class PKPartImporter
|
||||
{
|
||||
use PKImportHelperTrait;
|
||||
|
||||
public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $propertyAccessor)
|
||||
private string $base_currency;
|
||||
|
||||
public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $propertyAccessor, string $default_currency)
|
||||
{
|
||||
$this->em = $em;
|
||||
$this->propertyAccessor = $propertyAccessor;
|
||||
$this->base_currency = $default_currency;
|
||||
}
|
||||
|
||||
public function importParts(array $data): int
|
||||
@@ -195,6 +200,37 @@ class PKPartImporter
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currency for the given ISO code. If the currency does not exist, it is created.
|
||||
* This function returns null if the ISO code is the base currency.
|
||||
* @param string $currency_iso_code
|
||||
* @return Currency|null
|
||||
*/
|
||||
protected function getOrCreateCurrency(string $currency_iso_code): ?Currency
|
||||
{
|
||||
//Normalize ISO code
|
||||
$currency_iso_code = strtoupper($currency_iso_code);
|
||||
|
||||
//We do not have a currency for the base currency to be consistent with prices without currencies
|
||||
if ($currency_iso_code === $this->base_currency) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currency = $this->em->getRepository(Currency::class)->findOneBy([
|
||||
'iso_code' => $currency_iso_code,
|
||||
]);
|
||||
|
||||
if (!$currency) {
|
||||
$currency = new Currency();
|
||||
$currency->setIsoCode($currency_iso_code);
|
||||
$currency->setName(Currencies::getName($currency_iso_code));
|
||||
$this->em->persist($currency);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
return $currency;
|
||||
}
|
||||
|
||||
protected function importOrderdetails(array $data): void
|
||||
{
|
||||
if (!isset($data['partdistributor'])) {
|
||||
@@ -245,8 +281,18 @@ class PKPartImporter
|
||||
$orderdetail->addPricedetail($pricedetail);
|
||||
//Partkeepr stores the price per item, we need to convert it to the price per packaging unit
|
||||
$price_per_item = BigDecimal::of($partdistributor['price']);
|
||||
$pricedetail->setPrice($price_per_item->multipliedBy($partdistributor['packagingUnit']));
|
||||
$pricedetail->setPriceRelatedQuantity($partdistributor['packagingUnit'] ?? 1);
|
||||
$packaging_unit = $partdistributor['packagingUnit'] ?? 1;
|
||||
$pricedetail->setPrice($price_per_item->multipliedBy($packaging_unit));
|
||||
$pricedetail->setPriceRelatedQuantity($packaging_unit);
|
||||
//We have to set the minimum discount quantity to the packaging unit (PartKeepr does not know this concept)
|
||||
//But in Part-DB the minimum discount qty have to be unique across a orderdetail
|
||||
$pricedetail->setMinDiscountQuantity($packaging_unit);
|
||||
|
||||
//Set the currency of the price
|
||||
if (!empty($partdistributor['currency'])) {
|
||||
$currency = $this->getOrCreateCurrency($partdistributor['currency']);
|
||||
$pricedetail->setCurrency($currency);
|
||||
}
|
||||
|
||||
$this->em->persist($pricedetail);
|
||||
}
|
||||
|
||||
163
src/Services/LogSystem/LogDataFormatter.php
Normal file
163
src/Services/LogSystem/LogDataFormatter.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Services\LogSystem;
|
||||
|
||||
use App\Entity\LogSystem\AbstractLogEntry;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class LogDataFormatter
|
||||
{
|
||||
private const STRING_MAX_LENGTH = 1024;
|
||||
|
||||
private TranslatorInterface $translator;
|
||||
private EntityManagerInterface $entityManager;
|
||||
private ElementTypeNameGenerator $elementTypeNameGenerator;
|
||||
|
||||
public function __construct(TranslatorInterface $translator, EntityManagerInterface $entityManager, ElementTypeNameGenerator $elementTypeNameGenerator)
|
||||
{
|
||||
$this->translator = $translator;
|
||||
$this->entityManager = $entityManager;
|
||||
$this->elementTypeNameGenerator = $elementTypeNameGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the given data of a log entry as HTML
|
||||
* @param mixed $data
|
||||
* @param AbstractLogEntry $logEntry
|
||||
* @param string $fieldName
|
||||
* @return string
|
||||
*/
|
||||
public function formatData($data, AbstractLogEntry $logEntry, string $fieldName): string
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$tmp = '<span class="text-muted user-select-none">"</span>' . mb_strimwidth(htmlspecialchars($data), 0, self::STRING_MAX_LENGTH, ) . '<span class="text-muted user-select-none">"</span>';
|
||||
|
||||
//Show special characters and line breaks
|
||||
$tmp = preg_replace('/\n/', '<span class="text-muted user-select-none">\\n</span><br>', $tmp);
|
||||
$tmp = preg_replace('/\r/', '<span class="text-muted user-select-none">\\r</span>', $tmp);
|
||||
$tmp = preg_replace('/\t/', '<span class="text-muted user-select-none">\\t</span>', $tmp);
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
if (is_bool($data)) {
|
||||
return $this->formatBool($data);
|
||||
}
|
||||
|
||||
if (is_int($data)) {
|
||||
return (string) $data;
|
||||
}
|
||||
|
||||
if (is_float($data)) {
|
||||
return (string) $data;
|
||||
}
|
||||
|
||||
if (is_null($data)) {
|
||||
return '<i>null</i>';
|
||||
}
|
||||
|
||||
if (is_array($data)) {
|
||||
//If the array contains only one element with the key @id, it is a reference to another entity (foreign key)
|
||||
if (isset($data['@id'])) {
|
||||
return $this->formatForeignKey($data, $logEntry, $fieldName);
|
||||
}
|
||||
|
||||
//If the array contains a "date", "timezone_type" and "timezone" key, it is a DateTime object
|
||||
if (isset($data['date'], $data['timezone_type'], $data['timezone'])) {
|
||||
return $this->formatDateTime($data);
|
||||
}
|
||||
|
||||
|
||||
return $this->formatJSON($data);
|
||||
}
|
||||
|
||||
|
||||
throw new \RuntimeException('Type of $data not supported (' . gettype($data) . ')');
|
||||
}
|
||||
|
||||
private function formatJSON(array $data): string
|
||||
{
|
||||
$json = htmlspecialchars(json_encode($data, JSON_PRETTY_PRINT), ENT_QUOTES | ENT_SUBSTITUTE);
|
||||
|
||||
return sprintf(
|
||||
'<div data-controller="elements--json-formatter" data-json="%s"></div>',
|
||||
$json
|
||||
);
|
||||
}
|
||||
|
||||
private function formatForeignKey(array $data, AbstractLogEntry $logEntry, string $fieldName): string
|
||||
{
|
||||
//Extract the id from the @id key
|
||||
$id = $data['@id'];
|
||||
|
||||
try {
|
||||
//Retrieve the class type from the logEntry and retrieve the doctrine metadata
|
||||
$classMetadata = $this->entityManager->getClassMetadata($logEntry->getTargetClass());
|
||||
$fkTargetClass = $classMetadata->getAssociationTargetClass($fieldName);
|
||||
|
||||
//Try to retrieve the entity from the database
|
||||
$entity = $this->entityManager->getRepository($fkTargetClass)->find($id);
|
||||
|
||||
//If the entity was found, return a label for this entity
|
||||
if ($entity) {
|
||||
return $this->elementTypeNameGenerator->formatLabelHTMLForEntity($entity, true);
|
||||
} else { //Otherwise the entity was deleted, so return the id
|
||||
return $this->elementTypeNameGenerator->formatElementDeletedHTML($fkTargetClass, $id);
|
||||
}
|
||||
|
||||
|
||||
} catch (\InvalidArgumentException|\ReflectionException $exception) {
|
||||
return '<i>unknown target class</i>: ' . $id;
|
||||
}
|
||||
}
|
||||
|
||||
private function formatDateTime(array $data): string
|
||||
{
|
||||
if (!isset($data['date'], $data['timezone_type'], $data['timezone'])) {
|
||||
return '<i>unknown DateTime format</i>';
|
||||
}
|
||||
|
||||
$date = $data['date'];
|
||||
$timezoneType = $data['timezone_type'];
|
||||
$timezone = $data['timezone'];
|
||||
|
||||
if (!is_string($date) || !is_int($timezoneType) || !is_string($timezone)) {
|
||||
return '<i>unknown DateTime format</i>';
|
||||
}
|
||||
|
||||
try {
|
||||
$dateTime = new \DateTime($date, new \DateTimeZone($timezone));
|
||||
} catch (\Exception $exception) {
|
||||
return '<i>unknown DateTime format</i>';
|
||||
}
|
||||
|
||||
//Format it to the users locale
|
||||
$formatter = new \IntlDateFormatter(null, \IntlDateFormatter::MEDIUM, \IntlDateFormatter::MEDIUM);
|
||||
return $formatter->format($dateTime);
|
||||
}
|
||||
|
||||
private function formatBool(bool $data): string
|
||||
{
|
||||
return $data ? $this->translator->trans('true') : $this->translator->trans('false');
|
||||
}
|
||||
}
|
||||
76
src/Services/LogSystem/LogDiffFormatter.php
Normal file
76
src/Services/LogSystem/LogDiffFormatter.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Services\LogSystem;
|
||||
|
||||
use Jfcherng\Diff\DiffHelper;
|
||||
|
||||
class LogDiffFormatter
|
||||
{
|
||||
/**
|
||||
* Format the diff between the given data, depending on the type of the data.
|
||||
* If the diff is not possible, an empty string is returned.
|
||||
* @param $old_data
|
||||
* @param $new_data
|
||||
* @return string
|
||||
*/
|
||||
public function formatDiff($old_data, $new_data): string
|
||||
{
|
||||
if (is_string($old_data) && is_string($new_data)) {
|
||||
return $this->diffString($old_data, $new_data);
|
||||
}
|
||||
|
||||
if (is_numeric($old_data) && is_numeric($new_data)) {
|
||||
return $this->diffNumeric($old_data, $new_data);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function diffString(string $old_data, string $new_data): string
|
||||
{
|
||||
return DiffHelper::calculate($old_data, $new_data, 'Combined',
|
||||
[ //Diff options
|
||||
'context' => 2,
|
||||
],
|
||||
[ //Render options
|
||||
'detailLevel' => 'char',
|
||||
'showHeader' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function diffNumeric($old_data, $new_data): string
|
||||
{
|
||||
if ((!is_numeric($old_data)) || (!is_numeric($new_data))) {
|
||||
throw new \InvalidArgumentException('The given data is not numeric.');
|
||||
}
|
||||
|
||||
$difference = $new_data - $old_data;
|
||||
|
||||
//Positive difference
|
||||
if ($difference > 0) {
|
||||
return sprintf('<span class="text-success">+%s</span>', $difference);
|
||||
} else if ($difference < 0) {
|
||||
return sprintf('<span class="text-danger">%s</span>', $difference);
|
||||
} else {
|
||||
return sprintf('<span class="text-muted">%s</span>', $difference);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,9 +131,9 @@ class LogEntryExtraFormatter
|
||||
|
||||
if (($context instanceof LogWithEventUndoInterface) && $context->isUndoEvent()) {
|
||||
if ('undo' === $context->getUndoMode()) {
|
||||
$array['log.undo_mode.undo'] = (string) $context->getUndoEventID();
|
||||
$array['log.undo_mode.undo'] = '#' . $context->getUndoEventID();
|
||||
} elseif ('revert' === $context->getUndoMode()) {
|
||||
$array['log.undo_mode.revert'] = (string) $context->getUndoEventID();
|
||||
$array['log.undo_mode.revert'] = '#' . $context->getUndoEventID();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
80
src/Services/LogSystem/LogLevelHelper.php
Normal file
80
src/Services/LogSystem/LogLevelHelper.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Services\LogSystem;
|
||||
|
||||
use App\Entity\LogSystem\AbstractLogEntry;
|
||||
use Psr\Log\LogLevel;
|
||||
|
||||
class LogLevelHelper
|
||||
{
|
||||
/**
|
||||
* Returns the FontAwesome icon class for the given log level.
|
||||
* This returns just the specific icon class (so 'fa-info' for example).
|
||||
* @param string $logLevel The string representation of the log level (one of the LogLevel::* constants)
|
||||
* @return string
|
||||
*/
|
||||
public function logLevelToIconClass(string $logLevel): string
|
||||
{
|
||||
switch ($logLevel) {
|
||||
case LogLevel::DEBUG:
|
||||
return 'fa-bug';
|
||||
case LogLevel::INFO:
|
||||
return 'fa-info';
|
||||
case LogLevel::NOTICE:
|
||||
return 'fa-flag';
|
||||
case LogLevel::WARNING:
|
||||
return 'fa-exclamation-circle';
|
||||
case LogLevel::ERROR:
|
||||
return 'fa-exclamation-triangle';
|
||||
case LogLevel::CRITICAL:
|
||||
return 'fa-bolt';
|
||||
case LogLevel::ALERT:
|
||||
return 'fa-radiation';
|
||||
case LogLevel::EMERGENCY:
|
||||
return 'fa-skull-crossbones';
|
||||
default:
|
||||
return 'fa-question-circle';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Bootstrap table color class for the given log level.
|
||||
* @param string $logLevel The string representation of the log level (one of the LogLevel::* constants)
|
||||
* @return string The table color class (one of the 'table-*' classes)
|
||||
*/
|
||||
public function logLevelToTableColorClass(string $logLevel): string
|
||||
{
|
||||
|
||||
switch ($logLevel) {
|
||||
case LogLevel::EMERGENCY:
|
||||
case LogLevel::ALERT:
|
||||
case LogLevel::CRITICAL:
|
||||
case LogLevel::ERROR:
|
||||
return 'table-danger';
|
||||
case LogLevel::WARNING:
|
||||
return 'table-warning';
|
||||
case LogLevel::NOTICE:
|
||||
return 'table-info';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
94
src/Services/LogSystem/LogTargetHelper.php
Normal file
94
src/Services/LogSystem/LogTargetHelper.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Services\LogSystem;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Contracts\NamedElementInterface;
|
||||
use App\Entity\LogSystem\AbstractLogEntry;
|
||||
use App\Entity\LogSystem\UserNotAllowedLogEntry;
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use App\Entity\PriceInformations\Pricedetail;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Exceptions\EntityNotSupportedException;
|
||||
use App\Repository\LogEntryRepository;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class LogTargetHelper
|
||||
{
|
||||
protected EntityManagerInterface $em;
|
||||
protected LogEntryRepository $entryRepository;
|
||||
protected EntityURLGenerator $entityURLGenerator;
|
||||
protected ElementTypeNameGenerator $elementTypeNameGenerator;
|
||||
protected TranslatorInterface $translator;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager, EntityURLGenerator $entityURLGenerator,
|
||||
ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator)
|
||||
{
|
||||
$this->em = $entityManager;
|
||||
$this->entryRepository = $entityManager->getRepository(AbstractLogEntry::class);
|
||||
|
||||
$this->entityURLGenerator = $entityURLGenerator;
|
||||
$this->elementTypeNameGenerator = $elementTypeNameGenerator;
|
||||
$this->translator = $translator;
|
||||
}
|
||||
|
||||
private function configureOptions(OptionsResolver $resolver): self
|
||||
{
|
||||
$resolver->setDefault('show_associated', true);
|
||||
$resolver->setDefault('showAccessDeniedPath', true);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function formatTarget(AbstractLogEntry $context, array $options = []): string
|
||||
{
|
||||
$optionsResolver = new OptionsResolver();
|
||||
$this->configureOptions($optionsResolver);
|
||||
$options = $optionsResolver->resolve($options);
|
||||
|
||||
if ($context instanceof UserNotAllowedLogEntry && $options['showAccessDeniedPath']) {
|
||||
return htmlspecialchars($context->getPath());
|
||||
}
|
||||
|
||||
/** @var AbstractLogEntry $context */
|
||||
$target = $this->entryRepository->getTargetElement($context);
|
||||
|
||||
//If the target is null and the context has a target, that means that the target was deleted. Show it that way.
|
||||
if ($target === null) {
|
||||
if ($context->hasTarget()) {
|
||||
return $this->elementTypeNameGenerator->formatElementDeletedHTML($context->getTargetClass(),
|
||||
$context->getTargetId());
|
||||
}
|
||||
//If no target is set, we can't do anything
|
||||
return '';
|
||||
}
|
||||
|
||||
//Otherwise we can return a label for the target
|
||||
return $this->elementTypeNameGenerator->formatLabelHTMLForEntity($target, $options['show_associated']);
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,7 @@ class TimeTravel
|
||||
public function applyEntry(AbstractDBElement $element, TimeTravelInterface $logEntry): void
|
||||
{
|
||||
//Skip if this does not provide any info...
|
||||
if (!$logEntry->hasOldDataInformations()) {
|
||||
if (!$logEntry->hasOldDataInformation()) {
|
||||
return;
|
||||
}
|
||||
if (!$element instanceof TimeStampableInterface) {
|
||||
|
||||
@@ -34,6 +34,7 @@ use App\Entity\Parts\Supplier;
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Entity\UserSystem\Group;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Exceptions\EntityNotSupportedException;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Trees\TreeViewGenerator;
|
||||
@@ -71,6 +72,8 @@ final class EntityExtension extends AbstractExtension
|
||||
new TwigFunction('entity_type', [$this, 'getEntityType']),
|
||||
/* Returns the URL to the given entity */
|
||||
new TwigFunction('entity_url', [$this, 'generateEntityURL']),
|
||||
/* Returns the URL to the given entity in timetravel mode */
|
||||
new TwigFunction('timetravel_url', [$this, 'timeTravelURL']),
|
||||
/* Generates a JSON array of the given tree */
|
||||
new TwigFunction('tree_data', [$this, 'treeData']),
|
||||
|
||||
@@ -79,6 +82,15 @@ final class EntityExtension extends AbstractExtension
|
||||
];
|
||||
}
|
||||
|
||||
public function timeTravelURL(AbstractDBElement $element, \DateTimeInterface $dateTime): ?string
|
||||
{
|
||||
try {
|
||||
return $this->entityURLGenerator->timeTravelURL($element, $dateTime);
|
||||
} catch (EntityNotSupportedException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function treeData(AbstractDBElement $element, string $type = 'newEdit'): string
|
||||
{
|
||||
$tree = $this->treeBuilder->getTreeView(get_class($element), null, $type, $element);
|
||||
|
||||
47
src/Twig/LogExtension.php
Normal file
47
src/Twig/LogExtension.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Services\LogSystem\LogDataFormatter;
|
||||
use App\Services\LogSystem\LogDiffFormatter;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
final class LogExtension extends AbstractExtension
|
||||
{
|
||||
|
||||
private LogDataFormatter $logDataFormatter;
|
||||
private LogDiffFormatter $logDiffFormatter;
|
||||
|
||||
public function __construct(LogDataFormatter $logDataFormatter, LogDiffFormatter $logDiffFormatter)
|
||||
{
|
||||
$this->logDataFormatter = $logDataFormatter;
|
||||
$this->logDiffFormatter = $logDiffFormatter;
|
||||
}
|
||||
|
||||
public function getFunctions()
|
||||
{
|
||||
return [
|
||||
new TwigFunction('format_log_data', [$this->logDataFormatter, 'formatData'], ['is_safe' => ['html']]),
|
||||
new TwigFunction('format_log_diff', [$this->logDiffFormatter, 'formatDiff'], ['is_safe' => ['html']]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{# @var entry \App\Entity\LogSystem\CollectionElementDeleted #}
|
||||
|
||||
{% import "log_system/details/helper.macro.html.twig" as log_helper %}
|
||||
|
||||
<p class="m-0">
|
||||
<b>{% trans %}log.collection_deleted.deleted{% endtrans %}</b>:
|
||||
{{ entity_type_label(entry.deletedElementClass) }} #{{ entry.deletedElementID }}
|
||||
{% if entry.oldName is not empty %}
|
||||
({{ entry.oldName }})
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="m-0">
|
||||
<b>{% trans %}log.collection_deleted.on_collection{% endtrans %}</b>:
|
||||
{{ log_helper.translate_field(entry.collectionName) }}
|
||||
</p>
|
||||
@@ -0,0 +1,23 @@
|
||||
{# @var entry \App\Entity\LogSystem\DatabaseUpdatedLogEntry #}
|
||||
|
||||
{% if entry.successful %}
|
||||
<h5><span class="badge bg-success badge-success">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
{% trans %}log.database_updated.success{% endtrans %}
|
||||
</span></h5>
|
||||
{% else %}
|
||||
<h5><span class="badge bg-danger badge-danger">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
{% trans %}log.database_updated.failed{% endtrans %}</span>
|
||||
</h5>
|
||||
{% endif %}
|
||||
|
||||
<span class="badge bg-secondary badge-secondary badge-pill" title="{% trans %}log.database_updated.old_version{% endtrans %}">
|
||||
<i class="fa-solid fa-database"></i>
|
||||
{{ entry.oldVersion }}
|
||||
</span>
|
||||
<i class="fa-solid fa-arrow-right-long"></i>
|
||||
<span class="badge bg-primary badge-primary badge-pill" title="{% trans %}log.database_updated.new_version{% endtrans %}">
|
||||
<i class="fa-solid fa-database"></i>
|
||||
{{ entry.newVersion }}
|
||||
</span>
|
||||
@@ -0,0 +1,11 @@
|
||||
{# @var entry \App\Entity\LogSystem\ElementCreatedLogEntry #}
|
||||
|
||||
{% import "log_system/details/helper.macro.html.twig" as log_helper %}
|
||||
|
||||
{{ log_helper.comment_field(entry) }}
|
||||
{% if entry.creationInstockValue %}
|
||||
<p>
|
||||
<b>{% trans %}log.element_created.original_instock{% endtrans %}:</b>
|
||||
{{ entry.creationInstockValue }}
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{# @var entry \App\Entity\LogSystem\ElementDeletedLogEntry #}
|
||||
|
||||
{% import "log_system/details/helper.macro.html.twig" as log_helper %}
|
||||
|
||||
<span class="badge badge-notice"></span>
|
||||
|
||||
{{ log_helper.comment_field(entry) }}
|
||||
{{ log_helper.data_change_table(entry) }}
|
||||
11
templates/log_system/details/_extra_element_edited.html.twig
Normal file
11
templates/log_system/details/_extra_element_edited.html.twig
Normal file
@@ -0,0 +1,11 @@
|
||||
{# @var entry \App\Entity\LogSystem\ElementDeletedLogEntry #}
|
||||
|
||||
{% import "log_system/details/helper.macro.html.twig" as log_helper %}
|
||||
|
||||
{% if entry.undoEvent %}
|
||||
<span class="badge badge-info bg-info">Test</span>
|
||||
{% endif %}
|
||||
|
||||
{{ log_helper.comment_field(entry) }}
|
||||
|
||||
{{ log_helper.data_change_table(entry) }}
|
||||
@@ -0,0 +1,9 @@
|
||||
{# @var entry \App\Entity\LogSystem\UserLoginLogEntry #}
|
||||
|
||||
IP:
|
||||
<span class="badge bg-primary badge-primary">
|
||||
<i class="fa-solid fa-network-wired"></i>
|
||||
{{ entry.iPAddress }}
|
||||
</span>
|
||||
|
||||
<p class="text-muted">{% trans %}log.user_login.ip_anonymize_hint{% endtrans %}</p>
|
||||
9
templates/log_system/details/_extra_user_login.html.twig
Normal file
9
templates/log_system/details/_extra_user_login.html.twig
Normal file
@@ -0,0 +1,9 @@
|
||||
{# @var entry \App\Entity\LogSystem\UserLoginLogEntry #}
|
||||
|
||||
{% trans %}log.user_login.login_from_ip{% endtrans %}:
|
||||
<span class="badge bg-primary badge-primary">
|
||||
<i class="fa-solid fa-network-wired"></i>
|
||||
{{ entry.iPAddress }}
|
||||
</span>
|
||||
|
||||
<p class="text-muted">{% trans %}log.user_login.ip_anonymize_hint{% endtrans %}</p>
|
||||
@@ -0,0 +1,4 @@
|
||||
{# @var entry \App\Entity\LogSystem\UserNotAllowedLogEntry #}
|
||||
|
||||
{% trans %}log.user_not_allowed.unauthorized_access_attempt_to{% endtrans %}: <code>{{ entry.path }}</code>
|
||||
<p class="text-muted">{% trans %}log.user_not_allowed.hint{% endtrans %}</p>
|
||||
146
templates/log_system/details/helper.macro.html.twig
Normal file
146
templates/log_system/details/helper.macro.html.twig
Normal file
@@ -0,0 +1,146 @@
|
||||
{% macro undo_buttons(entry, target_element) %}
|
||||
{# @var entry \App\Entity\LogSystem\ElementEditedLogEntry|\App\Entity\LogSystem\ElementDeletedLogEntry entry #}
|
||||
{% set disabled = not is_granted('revert_element', entry.targetClass) %}
|
||||
|
||||
{% if entry is instanceof('App\\Entity\\LogSystem\\CollectionElementDeleted')
|
||||
or (entry is instanceof('App\\Entity\\LogSystem\\ElementDeletedLogEntry') and entry.hasOldDataInformation) %}
|
||||
|
||||
{% set icon = 'fa-trash-restore' %}
|
||||
{% set title = 'log.undo.undelete'|trans %}
|
||||
{% set title_short = 'log.undo.undelete.short'|trans %}
|
||||
|
||||
{% elseif entry is instanceof('App\\Entity\\LogSystem\\ElementCreatedLogEntry')
|
||||
or (entry is instanceof('App\\Entity\\LogSystem\\ElementEditedLogEntry') and entry.hasOldDataInformation) %}
|
||||
|
||||
{% set icon = 'fa-undo' %}
|
||||
{% set title = 'log.undo.undo'|trans %}
|
||||
{% set title_short = 'log.undo.undo.short'|trans %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ path("log_undo") }}"
|
||||
{{ stimulus_controller('elements/delete_btn') }} {{ stimulus_action('elements/delete_btn', "submit", "submit") }}
|
||||
data-delete-title="{% trans %}log.undo.confirm_title{% endtrans %}"
|
||||
data-delete-message="{% trans %}log.undo.confirm_message{% endtrans %}">
|
||||
|
||||
<input type="hidden" name="redirect_back" value="{{ app.request.requestUri }}">
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="submit" class="btn btn-outline-secondary" name="undo" value="{{ entry.id }}" {% if disabled %}disabled{% endif %}>
|
||||
<i class="fas fa-fw {{ icon }}" title="{{ title }}"></i> {{ title_short }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-outline-secondary" name="revert" value="{{ entry.id }}" {% if disabled %}disabled{% endif %}>
|
||||
<i class="fas fa-fw fa-backward" title="{% trans %}log.undo.revert{% endtrans %}"></i> {{ 'log.undo.revert.short' | trans }}
|
||||
</button>
|
||||
|
||||
{% set url = timetravel_url(target_element, entry.timestamp) %}
|
||||
|
||||
{# View button #}
|
||||
{% if target_element and ((attribute(entry, 'oldDataInformation') is defined and entry.oldDataInformation)
|
||||
or entry is instanceof('App\\Entity\\LogSystem\\CollectionElementDeleted'))
|
||||
and url is not null %}
|
||||
<a class="btn btn-outline-secondary" href="{{ url }}"><i class="fas fa-fw fa-eye"></i>
|
||||
{% trans %}log.view_version{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro comment_field(entry) %}
|
||||
{# @var entry \App\Entity\Contracts\LogWithComment #}
|
||||
<p class="mb-0">
|
||||
<b>{% trans %}edit.log_comment{% endtrans %}:</b>
|
||||
{% if entry.comment %}
|
||||
{{ entry.comment }}
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans %}log.no_comment{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro translate_field(field) %}
|
||||
{% set trans_key = 'log.element_edited.changed_fields.'~field %}
|
||||
{# If the translation key is not found, the translation key is returned, and we dont show the translation #}
|
||||
{% if trans_key|trans != trans_key %}
|
||||
{{ ('log.element_edited.changed_fields.'~field) | trans }}
|
||||
<span class="text-muted">({{ field }})</span>
|
||||
{% else %}
|
||||
{{ field }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro data_change_table(entry) %}
|
||||
{# @var entry \App\Entity\LogSystem\ElementEditedLogEntry|\App\Entity\LogSystem\ElementDeletedLogEntry entry #}
|
||||
|
||||
{% set fields, old_data, new_data = {}, {}, {} %}
|
||||
|
||||
{# For log entries where only the changed fields are saved, this is the last executed assignment #}
|
||||
{% if attribute(entry, 'changedFieldInfo') is defined and entry.changedFieldsInfo %}
|
||||
{% set fields = entry.changedFields %}
|
||||
{% endif %}
|
||||
|
||||
{# For log entries, where we know the old data, this is the last exectuted assignment #}
|
||||
{% if attribute(entry, 'oldDataInformation') is defined and entry.oldDataInformation %}
|
||||
{# We have to use the keys of oldData here, as changedFields might not be available #}
|
||||
{% set fields = entry.oldData | keys %}
|
||||
{% set old_data = entry.oldData %}
|
||||
{% endif %}
|
||||
|
||||
{# For log entries, where we have new data, we define it #}
|
||||
{% if attribute(entry, 'newDataInformation') is defined and entry.newDataInformation %}
|
||||
{# We have to use the keys of oldData here, as changedFields might not be available #}
|
||||
{% set fields = entry.newData | keys %}
|
||||
{% set new_data = entry.newData %}
|
||||
{% endif %}
|
||||
|
||||
{% if fields is not empty %}
|
||||
<table class="table table-hover table-striped table-sm table-bordered mt-2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans %}log.element_changed.field{% endtrans %}</th>
|
||||
{% if old_data is not empty %}
|
||||
<th>{% trans %}log.element_changed.data_before{% endtrans %}</th>
|
||||
{% endif %}
|
||||
{% if new_data is not empty %}
|
||||
<th>{% trans %}log.element_changed.data_after{% endtrans %}</th>
|
||||
{% endif %}
|
||||
{% if new_data is not empty and old_data is not empty %} {# Diff column #}
|
||||
<th>{% trans %}log.element_changed.diff{% endtrans %}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for field in fields %}
|
||||
<tr>
|
||||
<td title="{{ field }}">
|
||||
{{ _self.translate_field(field) }}
|
||||
</td>
|
||||
{% if old_data is not empty %}
|
||||
<td>
|
||||
{% if old_data[field] is defined %}
|
||||
{{ format_log_data(old_data[field], entry, field) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if new_data is not empty %}
|
||||
<td>
|
||||
{% if new_data[field] is defined %}
|
||||
{{ format_log_data(new_data[field], entry, field) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% if new_data is not empty and old_data is not empty %}
|
||||
<td>
|
||||
{% if new_data[field] is defined and old_data[field] is defined %}
|
||||
{{ format_log_diff(old_data[field], new_data[field]) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
117
templates/log_system/details/log_details.html.twig
Normal file
117
templates/log_system/details/log_details.html.twig
Normal file
@@ -0,0 +1,117 @@
|
||||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% import "helper.twig" as helper %}
|
||||
{% import "log_system/details/helper.macro.html.twig" as log_helper %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}log.details.title{% endtrans %}:
|
||||
{{ ('log.type.' ~ log_entry.type) | trans }} ({{ log_entry.timestamp | format_datetime('short') }})
|
||||
{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-binoculars"></i>
|
||||
{% trans %}log.details.title{% endtrans %}:
|
||||
<i>{{ ('log.type.' ~ log_entry.type) | trans }}</i> ({{ log_entry.timestamp | format_datetime('short') }})
|
||||
<span class="float-end">ID: {{ log_entry.iD }}</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block card_body %}
|
||||
<table class="table table-striped table-hover mb-0 {{ log_level_helper.logLevelToTableColorClass(log_entry.levelString) }}">
|
||||
<tr>
|
||||
<td>{% trans %}log.timestamp{% endtrans %}</td>
|
||||
<td>{{ log_entry.timestamp | format_datetime('full') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans %}log.type{% endtrans %}</td>
|
||||
<td>
|
||||
{{ ('log.type.' ~ log_entry.type) | trans }}
|
||||
{% if log_entry.type == 'part_stock_changed' %}
|
||||
({{ ('log.part_stock_changed.' ~ log_entry.instockChangeType)|trans }})
|
||||
{% endif %}
|
||||
|
||||
{% if log_entry is instanceof('App\\Entity\\Contracts\\LogWithEventUndoInterface') and log_entry.undoEvent %}
|
||||
<b>({{ ('log.undo_mode.' ~ log_entry.undoMode)|trans }}: <a href="{{ path('log_details', {"id": log_entry.UndoEventID}) }}">#{{ log_entry.UndoEventID }}</a>)</b>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans %}log.level{% endtrans %}</td>
|
||||
<td>
|
||||
<i class="fa-solid {{ log_level_helper.logLevelToIconClass(log_entry.levelString) }} fa-fw"></i>
|
||||
{{ ('log.level.'~ log_entry.levelString)|trans }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans %}log.user{% endtrans %}
|
||||
<td>
|
||||
{% if log_entry.cLIEntry %}
|
||||
<i class="fa-solid fa-terminal"></i>
|
||||
{{ log_entry.cLIUsername }} ({% trans %}log.cli_user{% endtrans %})
|
||||
{% else %}
|
||||
{% if log_entry.user %}
|
||||
{{ helper.user_icon_link(log_entry.user) }} (@{{ log_entry.user.username }})
|
||||
{% else %}
|
||||
@{{ log_entry.username }} ({% trans %}log.target_deleted{% endtrans %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans %}log.target{% endtrans %}</td>
|
||||
<td>{{ target_html|raw }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col-6">
|
||||
{% if log_entry is instanceof('App\\Entity\\LogSystem\\CollectionElementDeleted')
|
||||
or log_entry is instanceof('App\\Entity\\LogSystem\\ElementDeletedLogEntry')
|
||||
or log_entry is instanceof('App\\Entity\\LogSystem\\ElementCreatedLogEntry')
|
||||
or log_entry is instanceof('App\\Entity\\LogSystem\\ElementEditedLogEntry')
|
||||
%}
|
||||
{{ log_helper.undo_buttons(log_entry, target_element) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-6 text-end">
|
||||
<form method="post" class="" action="{{ path('log_delete', {'id': log_entry.iD}) }}" {{ stimulus_controller('elements/delete_btn') }} {{ stimulus_action('elements/delete_btn', "submit", "submit") }}
|
||||
data-delete-title="{% trans %}log.delete.message.title{% endtrans %}"
|
||||
data-delete-message="{% trans %}log.delete.message{% endtrans %}">
|
||||
|
||||
<input type="hidden" name="_method" value="DELETE">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ log_entry.id) }}">
|
||||
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" {% if not is_granted('delete', log_entry) %}disabled{% endif %}>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
{% trans %}log.details.delete_entry{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# This assignment is to improve autocomplete on the subpages, as PHPstorm ignores typehints for log_entry #}
|
||||
{% set entry = log_entry %}
|
||||
{% if log_entry is instanceof('App\\Entity\\LogSystem\\DatabaseUpdatedLogEntry') %}
|
||||
{% include "log_system/details/_extra_database_updated.html.twig" %}
|
||||
{% elseif log_entry is instanceof('App\\Entity\\LogSystem\\ElementCreatedLogEntry') %}
|
||||
{% include "log_system/details/_extra_element_created.html.twig" %}
|
||||
{% elseif log_entry is instanceof('App\\Entity\\LogSystem\\ElementEditedLogEntry') %}
|
||||
{% include "log_system/details/_extra_element_edited.html.twig" %}
|
||||
{% elseif log_entry is instanceof('App\\Entity\\LogSystem\\ElementDeletedLogEntry') %}
|
||||
{% include "log_system/details/_extra_element_deleted.html.twig" %}
|
||||
{% elseif log_entry is instanceof('App\\Entity\\LogSystem\\UserLoginLogEntry')
|
||||
or log_entry is instanceof('App\\Entity\\LogSystem\\UserLogoutLogEntry') %}
|
||||
{% include "log_system/details/_extra_user_login.html.twig" %}
|
||||
{% elseif log_entry is instanceof('App\\Entity\\LogSystem\\UserNotAllowedLogEntry') %}
|
||||
{% include "log_system/details/_extra_user_not_allowed.html.twig" %}
|
||||
{% elseif log_entry is instanceof('App\\Entity\\LogSystem\\SecurityEventLogEntry') %}
|
||||
{% include "log_system/details/_extra_security_event.html.twig" %}
|
||||
{% elseif log_entry is instanceof('App\\Entity\\LogSystem\\CollectionElementDeleted') %}
|
||||
{% include "log_system/details/_extra_collection_element_deleted.html.twig" %}
|
||||
{% else %}
|
||||
{{ extra_html | raw }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -4654,7 +4654,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>log.undo.undelete</source>
|
||||
<target>Bauteil wiederherstellen</target>
|
||||
<target>Element wiederherstellen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PI8faHR" name="log.undo.undo">
|
||||
@@ -11255,5 +11255,179 @@ Element 3</target>
|
||||
<target>Weniger als erwünscht</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cdnsW4q" name="log.cli_user">
|
||||
<segment state="translated">
|
||||
<source>log.cli_user</source>
|
||||
<target>CLI Benutzer</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4GTAJ9E" name="log.element_edited.changed_fields.part_owner_must_match">
|
||||
<segment state="translated">
|
||||
<source>log.element_edited.changed_fields.part_owner_must_match</source>
|
||||
<target>Der Bauteilebesitzer muss dem Lagerortbesitzer entsprechen!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="u6qFa_j" name="part.filter.lessThanDesired">
|
||||
<segment state="translated">
|
||||
<source>part.filter.lessThanDesired</source>
|
||||
<target>Weniger vorhanden als gewünscht (Gesamtmenge < Mindestmenge)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="lHTN.a1" name="part.filter.lotOwner">
|
||||
<segment state="translated">
|
||||
<source>part.filter.lotOwner</source>
|
||||
<target>Besitzer des Bestands</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="47OCK_W" name="user.show_email_on_profile.label">
|
||||
<segment state="translated">
|
||||
<source>user.show_email_on_profile.label</source>
|
||||
<target>E-Mail-Adresse auf öffentlicher Profilseite anzeigen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4rkjIk2" name="log.details.title">
|
||||
<segment state="translated">
|
||||
<source>log.details.title</source>
|
||||
<target>Logdetails</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="aeYMkHS" name="log.user_login.login_from_ip">
|
||||
<segment state="translated">
|
||||
<source>log.user_login.login_from_ip</source>
|
||||
<target>Login von IP-Adresse</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9jOklgS" name="log.user_login.ip_anonymize_hint">
|
||||
<segment state="translated">
|
||||
<source>log.user_login.ip_anonymize_hint</source>
|
||||
<target>Wenn die letzten Stellen der IP-Adresse fehlen, dann ist der DSGV Modus aktiviert, bei dem IP-Adressen anonymisiert werden.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kaMyDVi" name="log.user_not_allowed.unauthorized_access_attempt_to">
|
||||
<segment state="translated">
|
||||
<source>log.user_not_allowed.unauthorized_access_attempt_to</source>
|
||||
<target>Unerlaubter Zugriffsversuch auf Seite</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="EibB1Wh" name="log.user_not_allowed.hint">
|
||||
<segment state="translated">
|
||||
<source>log.user_not_allowed.hint</source>
|
||||
<target>Die Anfrage wurde blockiert. Es sollte keine weitere Aktion nötig sein.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="JVE17kW" name="log.no_comment">
|
||||
<segment state="translated">
|
||||
<source>log.no_comment</source>
|
||||
<target>Kein Kommentar</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5xvPvME" name="log.element_changed.field">
|
||||
<segment state="translated">
|
||||
<source>log.element_changed.field</source>
|
||||
<target>Feld</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vufWYhV" name="log.element_changed.data_before">
|
||||
<segment state="translated">
|
||||
<source>log.element_changed.data_before</source>
|
||||
<target>Daten vor Änderung</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qk2u9Qg" name="error_table.error">
|
||||
<segment state="translated">
|
||||
<source>error_table.error</source>
|
||||
<target>Während der Anfrage trat ein Fehler auf.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tLXzED2" name="part.table.invalid_regex">
|
||||
<segment state="translated">
|
||||
<source>part.table.invalid_regex</source>
|
||||
<target>Ungültiger regulärer Ausdruck (regex)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VKPOkNO" name="log.element_changed.data_after">
|
||||
<segment state="translated">
|
||||
<source>log.element_changed.data_after</source>
|
||||
<target>Daten nach Änderung</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="DiNGTl8" name="log.element_changed.diff">
|
||||
<segment state="translated">
|
||||
<source>log.element_changed.diff</source>
|
||||
<target>Unterschied</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="OB_fVDI" name="log.undo.undo.short">
|
||||
<segment state="translated">
|
||||
<source>log.undo.undo.short</source>
|
||||
<target>Rückgängig machen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="AvoT6DL" name="log.undo.revert.short">
|
||||
<segment state="translated">
|
||||
<source>log.undo.revert.short</source>
|
||||
<target>Auf Version zurücksetzen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="YdXQd2_" name="log.view_version">
|
||||
<segment state="translated">
|
||||
<source>log.view_version</source>
|
||||
<target>Version anzeigen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="l47W4kt" name="log.undo.undelete.short">
|
||||
<segment state="translated">
|
||||
<source>log.undo.undelete.short</source>
|
||||
<target>Wiederherstellen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PDJYeqj" name="log.element_edited.changed_fields.id">
|
||||
<segment state="translated">
|
||||
<source>log.element_edited.changed_fields.id</source>
|
||||
<target>ID</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cQTNNI7" name="log.element_edited.changed_fields.id_owner">
|
||||
<segment state="translated">
|
||||
<source>log.element_edited.changed_fields.id_owner</source>
|
||||
<target>Besitzer</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="h1eBlp8" name="log.element_edited.changed_fields.parent_id">
|
||||
<segment state="translated">
|
||||
<source>log.element_edited.changed_fields.parent_id</source>
|
||||
<target>Übergeordnetes Element</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GUthgcU" name="log.details.delete_entry">
|
||||
<segment state="translated">
|
||||
<source>log.details.delete_entry</source>
|
||||
<target>Logeintrag löschen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Wv0WAmO" name="log.delete.message.title">
|
||||
<segment state="translated">
|
||||
<source>log.delete.message.title</source>
|
||||
<target>Wollen Sie diesen Logeintrag wirklich löschen?</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5tbpaLR" name="log.delete.message">
|
||||
<segment state="translated">
|
||||
<source>log.delete.message</source>
|
||||
<target>Wenn dies ein Historieeintrag für ein Element ist, dann wird das Löschen zu Datenverlust der Historie führen! Dies kann unerwartete Ergebnisse liefern, wenn die Zeitreisefunktion verwendet wird.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="P.c4WmY" name="log.collection_deleted.on_collection">
|
||||
<segment state="translated">
|
||||
<source>log.collection_deleted.on_collection</source>
|
||||
<target>in Kollektion</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".wiLJk6" name="log.element_edited.changed_fields.attachments">
|
||||
<segment state="translated">
|
||||
<source>log.element_edited.changed_fields.attachments</source>
|
||||
<target>Dateianhänge</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="en">
|
||||
<file id="security.en">
|
||||
<unit id="aazoCks" name="user.login_error.user_disabled">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>user.login_error.user_disabled</source>
|
||||
<target>Your account is disabled! Contact an administrator if you think this is wrong.</target>
|
||||
</segment>
|
||||
@@ -10,7 +10,7 @@
|
||||
<unit id="Dpb9AmY" name="saml.error.cannot_login_local_user_per_saml">
|
||||
<segment state="translated">
|
||||
<source>saml.error.cannot_login_local_user_per_saml</source>
|
||||
<target>You can not login as local user via SSO! Use your local user password instead.</target>
|
||||
<target>You cannot login as local user via SSO! Use your local user password instead.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<note priority="1">Part-DB1\src\Entity\UserSystem\Group.php:0</note>
|
||||
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
|
||||
</notes>
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>part.master_attachment.must_be_picture</source>
|
||||
<target>The preview attachment must be a valid picture!</target>
|
||||
</segment>
|
||||
@@ -82,7 +82,7 @@
|
||||
<note priority="1">src\Entity\StructuralDBElement.php:0</note>
|
||||
<note priority="1">src\Entity\Supplier.php:0</note>
|
||||
</notes>
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>structural.entity.unique_name</source>
|
||||
<target>An element with this name already exists on this level!</target>
|
||||
</segment>
|
||||
@@ -102,7 +102,7 @@
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
|
||||
</notes>
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>parameters.validator.min_lesser_typical</source>
|
||||
<target>Value must be lesser or equal the the typical value ({{ compared_value }}).</target>
|
||||
</segment>
|
||||
@@ -122,7 +122,7 @@
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
|
||||
</notes>
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>parameters.validator.min_lesser_max</source>
|
||||
<target>Value must be lesser than the maximum value ({{ compared_value }}).</target>
|
||||
</segment>
|
||||
@@ -142,7 +142,7 @@
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
|
||||
</notes>
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>parameters.validator.max_greater_typical</source>
|
||||
<target>Value must be greater or equal than the typical value ({{ compared_value }}).</target>
|
||||
</segment>
|
||||
@@ -152,7 +152,7 @@
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
|
||||
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
|
||||
</notes>
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.user.username_already_used</source>
|
||||
<target>A user with this name is already exisiting</target>
|
||||
</segment>
|
||||
@@ -162,7 +162,7 @@
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
|
||||
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
|
||||
</notes>
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>user.invalid_username</source>
|
||||
<target>The username must contain only letters, numbers, underscores, dots, pluses or minuses!</target>
|
||||
</segment>
|
||||
@@ -171,7 +171,7 @@
|
||||
<notes>
|
||||
<note category="state" priority="1">obsolete</note>
|
||||
</notes>
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.noneofitschild.self</source>
|
||||
<target>An element can not be its own parent!</target>
|
||||
</segment>
|
||||
@@ -180,121 +180,121 @@
|
||||
<notes>
|
||||
<note category="state" priority="1">obsolete</note>
|
||||
</notes>
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.noneofitschild.children</source>
|
||||
<target>You can not assign children element as parent (This would cause loops)!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ayNr6QK" name="validator.select_valid_category">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.select_valid_category</source>
|
||||
<target>Please select a valid category!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="6vIlN5q" name="validator.part_lot.only_existing">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.only_existing</source>
|
||||
<target>Can not add new parts to this location as it is marked as "Only Existing"</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3xoKOIS" name="validator.part_lot.location_full.no_increase">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.location_full.no_increase</source>
|
||||
<target>Location is full. Amount can not be increased (new value must be smaller than {{ old_amount }}).</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="R6Ov4Yt" name="validator.part_lot.location_full">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.location_full</source>
|
||||
<target>Location is full. Can not add new parts to it.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BNQk2e7" name="validator.part_lot.single_part">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.single_part</source>
|
||||
<target>This location can only contain a single part and it is already full!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4gPskOG" name="validator.attachment.must_not_be_null">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.attachment.must_not_be_null</source>
|
||||
<target>You must select an attachment type!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cDDVrWT" name="validator.orderdetail.supplier_must_not_be_null">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.orderdetail.supplier_must_not_be_null</source>
|
||||
<target>You must select an supplier!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="k5DDdB4" name="validator.measurement_unit.use_si_prefix_needs_unit">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.measurement_unit.use_si_prefix_needs_unit</source>
|
||||
<target>To enable SI prefixes, you have to set a unit symbol!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="DuzIOCr" name="part.ipn.must_be_unique">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>part.ipn.must_be_unique</source>
|
||||
<target>The internal part number must be unique. {{ value }} is already in use!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Z4Kuuo2" name="validator.project.bom_entry.name_or_part_needed">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.project.bom_entry.name_or_part_needed</source>
|
||||
<target>You have to choose a part for a part BOM entry or set a name for a non-part BOM entry.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WF_v4ih" name="project.bom_entry.name_already_in_bom">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.name_already_in_bom</source>
|
||||
<target>There is already an BOM entry with this name!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5v4p85H" name="project.bom_entry.part_already_in_bom">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.part_already_in_bom</source>
|
||||
<target>This part already exists in the BOM!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3lM32Tw" name="project.bom_entry.mountnames_quantity_mismatch">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.mountnames_quantity_mismatch</source>
|
||||
<target>The number of mountnames has to match the BOMs quantity!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="x47D5WT" name="project.bom_entry.can_not_add_own_builds_part">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.can_not_add_own_builds_part</source>
|
||||
<target>You can not add a project's own builds part to the BOM.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2x2XDI_" name="project.bom_has_to_include_all_subelement_parts">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>project.bom_has_to_include_all_subelement_parts</source>
|
||||
<target>The project BOM has to include all subprojects builds parts. Part %part_name% of project %project_name% missing!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="U9b1EzD" name="project.bom_entry.price_not_allowed_on_parts">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.price_not_allowed_on_parts</source>
|
||||
<target>Prices are not allowed on BOM entries associated with a part. Define the price on the part instead.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ID056SR" name="validator.project_build.lot_bigger_than_needed">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.project_build.lot_bigger_than_needed</source>
|
||||
<target>You have selected more quantity to withdraw than needed! Remove unnecessary quantity.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="6hV5UqD" name="validator.project_build.lot_smaller_than_needed">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.project_build.lot_smaller_than_needed</source>
|
||||
<target>You have selected less quantity to withdraw than needed for the build! Add additional quantity.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="G9ZKt.4" name="part.name.must_match_category_regex">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>part.name.must_match_category_regex</source>
|
||||
<target>The part name does not match the regular expression stated by the category: %regex%</target>
|
||||
</segment>
|
||||
|
||||
Reference in New Issue
Block a user