diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php index 12c7f0d0..cbe8344f 100644 --- a/src/Services/ElementTypeNameGenerator.php +++ b/src/Services/ElementTypeNameGenerator.php @@ -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( + '%s', + $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( + '%s: %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( + ' (%s)', + $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( + '%s: %s [%s]', + $this->getLocalizedTypeLabel($class), + $id, + $this->translator->trans('log.target_deleted') + ); + } } diff --git a/src/Services/LogSystem/LogDataFormatter.php b/src/Services/LogSystem/LogDataFormatter.php new file mode 100644 index 00000000..c6ecd84f --- /dev/null +++ b/src/Services/LogSystem/LogDataFormatter.php @@ -0,0 +1,146 @@ +. + */ + +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)) { + return '"' . mb_strimwidth(htmlspecialchars($data), 0, self::STRING_MAX_LENGTH, ) . '"'; + } + + 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 'null'; + } + + 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 htmlspecialchars(json_encode($data, JSON_PRETTY_PRINT)); + } + + + throw new \RuntimeException('Type of $data not supported (' . gettype($data) . ')'); + } + + 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 'unknown target class: ' . $id; + } + } + + private function formatDateTime(array $data): string + { + if (!isset($data['date'], $data['timezone_type'], $data['timezone'])) { + return 'unknown DateTime format'; + } + + $date = $data['date']; + $timezoneType = $data['timezone_type']; + $timezone = $data['timezone']; + + if (!is_string($date) || !is_int($timezoneType) || !is_string($timezone)) { + return 'unknown DateTime format'; + } + + try { + $dateTime = new \DateTime($date, new \DateTimeZone($timezone)); + } catch (\Exception $exception) { + return 'unknown DateTime format'; + } + + //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'); + } +} \ No newline at end of file diff --git a/src/Services/LogSystem/LogTargetHelper.php b/src/Services/LogSystem/LogTargetHelper.php index 852afa16..63946a0f 100644 --- a/src/Services/LogSystem/LogTargetHelper.php +++ b/src/Services/LogSystem/LogTargetHelper.php @@ -78,64 +78,17 @@ class LogTargetHelper /** @var AbstractLogEntry $context */ $target = $this->entryRepository->getTargetElement($context); - $tmp = ''; - - //The element is existing - if ($target instanceof NamedElementInterface && !empty($target->getName())) { - try { - $tmp = sprintf( - '%s', - $this->entityURLGenerator->infoURL($target), - $this->elementTypeNameGenerator->getTypeNameCombination($target, true) - ); - } catch (EntityNotSupportedException $exception) { - $tmp = $this->elementTypeNameGenerator->getTypeNameCombination($target, true); + //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()); } - } elseif ($target instanceof AbstractDBElement) { //Target does not have a name - $tmp = sprintf( - '%s: %s', - $this->elementTypeNameGenerator->getLocalizedTypeLabel($target), - $target->getID() - ); - } elseif (null === $target && $context->hasTarget()) { //Element was deleted - $tmp = sprintf( - '%s: %s [%s]', - $this->elementTypeNameGenerator->getLocalizedTypeLabel($context->getTargetClass()), - $context->getTargetID(), - $this->translator->trans('log.target_deleted') - ); + //If no target is set, we can't do anything + return ''; } - //Add a hint to the associated element if possible - if (null !== $target && $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( - ' (%s)', - $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; + //Otherwise we can return a label for the target + return $this->elementTypeNameGenerator->formatLabelHTMLForEntity($target, $options['show_associated']); } } \ No newline at end of file diff --git a/src/Twig/LogExtension.php b/src/Twig/LogExtension.php new file mode 100644 index 00000000..7440a9b4 --- /dev/null +++ b/src/Twig/LogExtension.php @@ -0,0 +1,40 @@ +. + */ + +namespace App\Twig; + +use App\Services\LogSystem\LogDataFormatter; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +class LogExtension extends AbstractExtension +{ + public function __construct(LogDataFormatter $logDataFormatter) + { + $this->logDataFormatter = $logDataFormatter; + } + + public function getFunctions() + { + return [ + new TwigFunction('format_log_data', [$this->logDataFormatter, 'formatData'], ['is_safe' => ['html']]) + ]; + } +} \ No newline at end of file diff --git a/templates/log_system/details/_extra_element_created.html.twig b/templates/log_system/details/_extra_element_created.html.twig new file mode 100644 index 00000000..8f7ce457 --- /dev/null +++ b/templates/log_system/details/_extra_element_created.html.twig @@ -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 %} +

+ {% trans %}log.element_created.original_instock{% endtrans %}: + {{ entry.creationInstockValue }} +

+{% endif %} \ No newline at end of file diff --git a/templates/log_system/details/_extra_element_deleted.html.twig b/templates/log_system/details/_extra_element_deleted.html.twig new file mode 100644 index 00000000..ceb9e9e2 --- /dev/null +++ b/templates/log_system/details/_extra_element_deleted.html.twig @@ -0,0 +1,8 @@ +{# @var entry \App\Entity\LogSystem\ElementDeletedLogEntry #} + +{% import "log_system/details/helper.macro.html.twig" as log_helper %} + + + +{{ log_helper.comment_field(entry) }} +{{ log_helper.data_change_table(entry) }} \ No newline at end of file diff --git a/templates/log_system/details/_extra_element_edited.html.twig b/templates/log_system/details/_extra_element_edited.html.twig new file mode 100644 index 00000000..f0c2bc75 --- /dev/null +++ b/templates/log_system/details/_extra_element_edited.html.twig @@ -0,0 +1,11 @@ +{# @var entry \App\Entity\LogSystem\ElementDeletedLogEntry #} + +{% import "log_system/details/helper.macro.html.twig" as log_helper %} + +{% if entry.undoEvent %} + Test +{% endif %} + +{{ log_helper.comment_field(entry) }} + +{{ log_helper.data_change_table(entry) }} diff --git a/templates/log_system/details/helper.macro.html.twig b/templates/log_system/details/helper.macro.html.twig new file mode 100644 index 00000000..d867c6d1 --- /dev/null +++ b/templates/log_system/details/helper.macro.html.twig @@ -0,0 +1,77 @@ + +{% macro comment_field(entry) %} + {# @var entry \App\Entity\Contracts\LogWithComment #} +

+ {% trans %}edit.log_comment{% endtrans %}: + {% if entry.comment %} + {{ entry.comment }} + {% else %} + {% trans %}log.no_comment{% endtrans %} + {% 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, 'oldDataInformations') is defined and entry.oldDataInformations %} + {# 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 %} + + {% if fields is not empty %} + + + + + {% if old_data is not empty %} + + {% endif %} + {% if new_data is not empty %} + + {% endif %} + + + + {% for field in fields %} + + + {% if old_data is not empty %} + + {% endif %} + {% if new_data is not empty %} + + {% endif %} + + {% endfor %} + +
{% trans %}log.element_changed.field{% endtrans %}{% trans %}log.element_changed.data_before{% endtrans %}{% trans %}log.element_changed.data_after{% endtrans %}
+ {% 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 }} + ({{ field }}) + {% else %} + {{ field }} + {% endif %} + + + {% if old_data[field] is defined %} + {{ format_log_data(old_data[field], entry, field) }} + {% endif %} + + {% if new_data[field] is defined %} + {{ format_log_data(new_data[field], entry, field) }} + {% endif %} +
+ {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/templates/log_system/details/log_details.html.twig b/templates/log_system/details/log_details.html.twig index ff27be73..c2cee6a9 100644 --- a/templates/log_system/details/log_details.html.twig +++ b/templates/log_system/details/log_details.html.twig @@ -62,6 +62,12 @@ {% 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" %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 2c774d5f..bd02067d 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -10960,31 +10960,31 @@ Element 3 - + attachment.max_file_size Maximum file size - + user.saml_user SSO / SAML user - + user.saml_user.pw_change_hint Your user uses single sign-on (SSO). You can not change the password and 2FA settings here. Configure them on your central SSO provider instead! - + login.sso_saml_login Single Sign-On Login (SSO) - + login.local_login_hint The form below is only for log in with a local user. If you want to log in via single sign-on, press the button above. @@ -11188,103 +11188,103 @@ Element 3 - + measurement_unit.new New Measurement Unit - + measurement_unit.edit Edit Measurement Unit - + user.aboutMe.label About Me - + storelocation.owner.label Owner - + storelocation.part_owner_must_match.label Part Lot owner must match storage location owner - + part_lot.owner Owner - + part_lot.owner.help Only the owner can withdraw or add stock to this lot. - + log.element_edited.changed_fields.owner Owner - + log.element_edited.changed_fields.instock_unknown Amount unknown - + log.element_edited.changed_fields.needs_refill Refill needed - + part.withdraw.access_denied Not allowed to do the desired action. Please check your permissions and the owner of the part lots. - + part.info.amount.less_than_desired Less than desired - + log.cli_user CLI user - + log.element_edited.changed_fields.part_owner_must_match Part owner must match storage location owner - + part.filter.lessThanDesired - In stock less than desired (total amount < min. amount) + - + part.filter.lotOwner Lot owner - + user.show_email_on_profile.label Show email on public profile page @@ -11319,5 +11319,23 @@ Element 3 The request was blocked. No action should be required. + + + log.no_comment + No comment + + + + + log.element_changed.field + Field + + + + + log.element_changed.data_before + Data before change + + diff --git a/translations/security.en.xlf b/translations/security.en.xlf index 03d410c0..2c9d8957 100644 --- a/translations/security.en.xlf +++ b/translations/security.en.xlf @@ -8,7 +8,7 @@ - + saml.error.cannot_login_local_user_per_saml You can not login as local user via SSO! Use your local user password instead. diff --git a/translations/validators.en.xlf b/translations/validators.en.xlf index a24e6a0d..1f3438de 100644 --- a/translations/validators.en.xlf +++ b/translations/validators.en.xlf @@ -300,19 +300,19 @@ - + validator.attachment.name_not_blank Set a value here, or upload a file to automatically use its filename as name for the attachment. - + validator.part_lot.owner_must_match_storage_location_owner The owner of this lot must match the owner of the selected storage location (%owner_name%)! - + validator.part_lot.owner_must_not_be_anonymous A lot owner must not be the anonymous user!