Compare commits

..

57 Commits

Author SHA1 Message Date
Jan Böhmer
636776c531 Bumped version to 1.4.1 2023-06-06 23:22:39 +02:00
Jan Böhmer
ca4a33d408 Merge remote-tracking branch 'origin/l10n_master' 2023-06-06 23:21:44 +02:00
Jan Böhmer
9db158f4d4 Updated dependencies 2023-06-06 23:20:51 +02:00
Jan Böhmer
ea8b179df1 Added timetravel URL for PartAttachment elements 2023-06-06 23:16:51 +02:00
Jan Böhmer
efc152e3c8 Do not throw an exception during rendering of log detail page, if element has no time travel URL 2023-06-06 23:15:14 +02:00
Jan Böhmer
e68827bf3b Show a validation error message, when try to submit a form where a input is still set to a disabled value.
Normally this would just send a null to the server, which often cause excptions. We now catch that earlier, and say the user that he have to select another option, when he tries to submit
2023-06-06 23:05:44 +02:00
Jan Böhmer
58bf69882f Updated dependencies. 2023-06-05 22:15:07 +02:00
Jan Böhmer
915f313efd New translations security.en.xlf (English) 2023-05-28 18:05:45 +02:00
Jan Böhmer
52d29099a2 New translations messages.en.xlf (English) 2023-05-28 18:05:44 +02:00
japm48
c06fc926a1 Update translation (#295)
* Update security.en.xlf

* Update messages.en.xlf
2023-05-28 18:02:02 +02:00
japm48
7640ed08bc docker: add missing PassEnv directives (#294) 2023-05-27 23:59:21 +02:00
Jan Böhmer
9c4e9066f9 Bump to version 1.4.0 2023-05-27 19:29:47 +02:00
Jan Böhmer
b4d1af2bce Merge remote-tracking branch 'origin/l10n_master' 2023-05-27 19:29:28 +02:00
Jan Böhmer
5ec676c40c Fixed static analysis issue 2023-05-27 19:29:00 +02:00
Jan Böhmer
5096aea5bb New translations security.en.xlf (English) 2023-05-27 19:26:51 +02:00
Jan Böhmer
feedd190dc New translations validators.en.xlf (English) 2023-05-27 19:26:51 +02:00
Jan Böhmer
3423fffaca New translations messages.en.xlf (English) 2023-05-27 19:26:50 +02:00
Jan Böhmer
1624fd2e28 New translations security.en.xlf (German) 2023-05-27 19:26:42 +02:00
Jan Böhmer
10b3094d5e New translations validators.en.xlf (German) 2023-05-27 19:26:42 +02:00
Jan Böhmer
580e638f67 New translations messages.en.xlf (German) 2023-05-27 19:26:41 +02:00
Jan Böhmer
e44428f87c Updated dependencies. 2023-05-27 19:24:14 +02:00
Jan Böhmer
379f7ef865 Implemented proper voters for attachments and parameters, so we can decide access for log details 2023-05-27 19:17:27 +02:00
Jan Böhmer
427f6e4d55 Merge remote-tracking branch 'origin/l10n_master' 2023-05-23 23:12:56 +02:00
Jan Böhmer
07a1e9fc3c New translations messages.en.xlf (English) 2023-05-23 23:09:42 +02:00
Jan Böhmer
78d64e8f1b New translations messages.en.xlf (German) 2023-05-23 23:09:32 +02:00
Jan Böhmer
559a9a9f3e New translations messages.en.xlf (German) 2023-05-23 22:45:26 +02:00
Jan Böhmer
ac6dd23fd6 Respect different currencies for pricedetails when importing from PartKeepr 2023-05-22 23:34:58 +02:00
Jan Böhmer
1e515df0b5 Fixed previous commit: Use the same behavior to determine the extension of file attachments like PartKeepr does, to ensure that all attachments are shown as available
This fixes issue #291
2023-05-22 23:06:41 +02:00
Jan Böhmer
35490762a6 Use the same behavior to determine the extension of file attachments like PartKeepr does, to ensure that all attachments are shown as available
This fixes issue #291
2023-05-22 22:55:18 +02:00
Jan Böhmer
c25e23d3d9 New translations messages.en.xlf (English) 2023-05-18 23:36:43 +02:00
Jan Böhmer
8bb8257e62 Added a log entry detail page for collection element deleted log entries. 2023-05-18 23:05:40 +02:00
Jan Böhmer
5f096927bd New translations messages.en.xlf (English) 2023-05-16 00:17:44 +02:00
Jan Böhmer
434826c125 Use default CodeQL workflow which is configured via repo settings and not via a action file 2023-05-16 00:16:50 +02:00
Jan Böhmer
89595cd5dc We are in development of version 1.4.0 now 2023-05-16 00:08:57 +02:00
Jan Böhmer
d991e15a94 Merge branch 'log_detail_page' 2023-05-16 00:08:12 +02:00
Jan Böhmer
6a1aefa5a5 Allow access to log detail page (only) if a user has permission to show_history of an entity 2023-05-16 00:05:54 +02:00
Jan Böhmer
272684e7eb Visualize generic object/JSON data of element history data as pretty tree structure on log detail page 2023-05-15 23:55:36 +02:00
Jan Böhmer
9be3eba694 Added button to delete a log entry via the log detail page. 2023-05-15 23:02:30 +02:00
Jan Böhmer
5a3fc0fb43 Show and link which log entry was undone/reverted on log detail page 2023-05-15 22:42:08 +02:00
Jan Böhmer
47ef8e9568 Updated dependencies 2023-05-15 00:36:36 +02:00
Jan Böhmer
e4285bbc78 delete_btn_controller: Keep the value and name of the original clicked button
This fixes an error message when undoing or reverting a log entry
2023-05-15 00:34:06 +02:00
Jan Böhmer
49b6a42791 Added buttons for revert and undo to the log detail page 2023-05-15 00:16:49 +02:00
Jan Böhmer
b62fd602f2 Show the diff of element edited log entries on detail pages 2023-05-14 23:08:14 +02:00
Jan Böhmer
923e40ed8f Add the data after the change to a element edited log entry, so you can easily view the changes in log detail pages 2023-05-14 21:41:00 +02:00
Jan Böhmer
3c724a227a Merge branch 'master' into log_detail_page 2023-05-14 16:43:52 +02:00
Jan Böhmer
90d26eb16a New translations messages.en.xlf (English) 2023-05-09 01:18:42 +02:00
Jan Böhmer
b629744e1a We are in development of v1.3.4 now 2023-05-09 00:27:18 +02:00
Jan Böhmer
b0ab43c39a Show a proper error message table when encountering an invalid regex statement on SQLite
This is related to #289
2023-05-09 00:26:40 +02:00
Jan Böhmer
2c33b381c1 Allow to unselect name, category, description fields etc in search functionm
Before this commit it was ignored, if the checkboxes for these fields were unchecked.
2023-05-08 23:53:59 +02:00
Jan Böhmer
c50a80e8df Show an error message in table instead of a 500 error when MySQL encounters an invalid Regex expression
This fixes issue #289
2023-05-08 23:42:25 +02:00
Jan Böhmer
1534f780aa Show a table with the old data in log entry details page 2023-05-01 01:38:14 +02:00
Jan Böhmer
4c6ceab8e8 Merge branch 'master' into log_detail_page 2023-04-29 22:46:38 +02:00
Jan Böhmer
f3fc01b740 New translations security.en.xlf (English) 2023-04-11 13:48:44 +02:00
Jan Böhmer
a201be5a01 New translations validators.en.xlf (English) 2023-04-11 13:48:43 +02:00
Jan Böhmer
ebf2035351 New translations messages.en.xlf (English) 2023-04-11 13:48:42 +02:00
Jan Böhmer
69fc28d5d6 Added better formatted extra section for certain log types 2023-04-10 23:13:09 +02:00
Jan Böhmer
4107535b19 Added basic log entry info page 2023-04-10 00:30:23 +02:00
58 changed files with 4468 additions and 2526 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -1 +1 @@
1.3.3
1.4.1

View File

@@ -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 {

View 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());
}
}

View File

@@ -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() {

View File

@@ -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;
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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' }

View File

@@ -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.

View File

@@ -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",

View File

@@ -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"})
*/

View File

@@ -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;
}

View File

@@ -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'],
]);
}
}

View File

@@ -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');

View 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();
}
}

View File

@@ -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 {

View File

@@ -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);
}
});
}
}

View 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;
}

View File

@@ -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.

View File

@@ -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']);
}

View File

@@ -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']);
}

View File

@@ -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';
}
/**

View File

@@ -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);
}
}
/**

View 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);
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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')
);
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}

View 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');
}
}

View File

@@ -0,0 +1,76 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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);
}
}
}

View File

@@ -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();
}
}

View 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 '';
}
}
}

View 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']);
}
}

View File

@@ -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) {

View File

@@ -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
View 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']]),
];
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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) }}

View 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) }}

View File

@@ -0,0 +1,9 @@
{# @var entry \App\Entity\LogSystem\UserLoginLogEntry #}
IP: &nbsp;
<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>

View File

@@ -0,0 +1,9 @@
{# @var entry \App\Entity\LogSystem\UserLoginLogEntry #}
{% trans %}log.user_login.login_from_ip{% endtrans %}: &nbsp;
<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>

View File

@@ -0,0 +1,4 @@
{# @var entry \App\Entity\LogSystem\UserNotAllowedLogEntry #}
{% trans %}log.user_not_allowed.unauthorized_access_attempt_to{% endtrans %}: &nbsp;<code>{{ entry.path }}</code>
<p class="text-muted">{% trans %}log.user_not_allowed.hint{% endtrans %}</p>

View 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 %}

View 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 %}

View File

@@ -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 &lt; 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

View File

@@ -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>

View 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>

1341
yarn.lock

File diff suppressed because it is too large Load Diff