Compare commits

...

19 Commits

Author SHA1 Message Date
jf-cbd
1d88c89c9f WIP 2026-06-02 18:18:27 +02:00
jf-cbd
41726b0cc9 WIP on new user read-only profiles 2026-05-29 17:11:12 +02:00
Anne-Cath
f236dd6c4d N°1681 - Add new trigger for attachment creation - Add deleted attachment to notification email and fix dictionaries 2026-05-26 16:55:30 +02:00
Vincent Dumas
ba2af7ed63 N°9604 - add "flowmaps" as a new neighbour (#914)
* Set DataFlow as FunctionalCI
* Use class name for Badge ID on DataFlow and Container classes including Typology
* Improve DataFlow summary
---------
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-05-21 11:22:10 +02:00
Benjamin DALSASS
48e6203869 N°9044 - Application token and Impersonate (Log in as this user) 2026-05-13 11:14:58 +02:00
jf-cbd
ab6c50d52d 👷 Update action.yml 2026-05-07 16:24:40 +02:00
jf-cbd
51e7ef32dc 👷 Update action.yml 2026-05-07 16:23:11 +02:00
Molkobain
5ac675a587 Merge remote-tracking branch 'origin/support/3.2' into develop 2026-05-06 14:53:01 +02:00
Molkobain
769afb2715 Merge remote-tracking branch 'origin/support/3.2.3' into support/3.2 2026-05-06 14:52:10 +02:00
Molkobain
b529a61bc5 Fix PHP code styles 2026-05-06 13:50:58 +02:00
v-dumas
c501280f53 N°7771 - Align Dataflow option with product-itop installation.xml 2026-05-06 11:55:25 +02:00
Vincent Dumas
e662370c32 N°7771 New module for DataFlow management (#905) 2026-05-06 11:28:21 +02:00
Molkobain
3c39c6d8d1 Merge remote-tracking branch 'origin/support/3.2' into develop 2026-05-06 11:19:30 +02:00
Molkobain
a7d0262b21 Merge remote-tracking branch 'origin/support/3.2.3' into support/3.2 2026-05-06 11:18:36 +02:00
Molkobain
c56617abf5 N°8579 - Update PHPDoc 2026-05-06 10:55:39 +02:00
v-dumas
ff94639a61 N°9471 - Rack can contain Enclosure 2026-05-05 10:46:34 +02:00
Molkobain
7676115725 N°9584 - Fix "Unable to connect with STARTTLS" error when sending emails (#901)
* N°9584 - Fix "Unable to connect with STARTTLS" error when sending emails

* N°9584 - Refactor EMailSymfony transport to add unit tests
2026-05-05 09:51:09 +02:00
Molkobain
43ceaeb5a3 Merge remote-tracking branch 'origin/support/3.2.3' into support/3.2 2026-05-04 16:42:30 +02:00
Håkon Harnes
7cac280b83 N°9574 - Fix CKEditor CSS displayed as part of the email message in Gmail (#898)
* fix(email): generate plain text before inlining HTML CSS

* N°9574 - Add unit tests

* Apply suggestions from code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

---------

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>
2026-05-04 16:33:38 +02:00
39 changed files with 1875 additions and 91 deletions

View File

@@ -1,9 +1,13 @@
name: Add PRs to Combodo PRs Dashboard
on:
pull_request_target:
pull_request:
types:
- opened
issues:
types:
- opened
workflow_call:
jobs:
add-to-project:
@@ -31,23 +35,22 @@ jobs:
run: |
curl -X POST -H "Authorization: token ${{ secrets.PR_AUTOMATICALLY_ADD_TO_PROJECT }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/Combodo/iTop/issues/${{ github.event.pull_request.number }}/labels \
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels \
-d '{"labels":["internal"]}'
- name: Set PR author as assignee if member of the organization
if: env.is_member == 'true'
if: env.is_member == 'true' && github.event_name == 'pull_request'
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.PR_AUTOMATICALLY_ADD_TO_PROJECT }}" \
https://api.github.com/repos/Combodo/iTop/issues/${{ github.event.pull_request.number }}/assignees \
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/assignees \
-d '{"assignees":["${{ github.event.pull_request.user.login }}"]}'
env:
is_member: ${{ env.is_member }}
- name: Add PR to the appropriate project
uses: actions/add-to-project@v1.0.2
uses: actions/add-to-project@v2
with:
project-url: ${{ env.project_url }}
github-token: ${{ secrets.PR_AUTOMATICALLY_ADD_TO_PROJECT }}

View File

@@ -658,6 +658,16 @@ abstract class User extends cmdbAbstractObject
}
return false;
}
/**
* Allow a user to be impersonated by another one (generally an administrator) in order to troubleshoot rights issues.
*
* @return bool
*/
public function CanBeImpersonated(): bool
{
return true;
}
}
/**
@@ -1063,6 +1073,9 @@ class UserRights
$oUser = self::FindUser($sLogin);
if ($oUser) {
$bRet = true;
if (!$oUser->CanBeImpersonated()) {
throw new Exception($oUser->GetName().' cannot be impersonated');
}
if (is_null(self::$m_oRealUser)) {
// First impersonation
self::$m_oRealUser = self::$m_oUser;
@@ -1528,8 +1541,8 @@ class UserRights
*/
public static function IsActionAllowed($sClass, $iActionCode, $oInstanceSet = null, $oUser = null)
{
// When initializing, we need to let everything pass trough
if (!self::CheckLogin()) {
// When initializing, we need to let everything pass through
if (is_null($oUser) && !self::CheckLogin()) {
return UR_ALLOWED_YES;
}

View File

@@ -54,6 +54,15 @@
</modules>
<default>true</default>
</choice>
<choice>
<extension_code>itop-flow-map</extension_code>
<title>Application Data Flows</title>
<description>Modelize flows between your applications, with impacts analysis</description>
<modules type="array">
<module>itop-flow-map</module>
</modules>
<default>true</default>
</choice>
<choice>
<extension_code>itop-config-mgmt-storage</extension_code>
<title>Storage Devices</title>
@@ -80,11 +89,20 @@
<modules type="array">
<module>itop-container-mgmt</module>
</modules>
<default>false</default>
<default>true</default>
</choice>
</options>
</sub_options>
</choice>
<choice>
<extension_code>itop-flow-map</extension_code>
<title>Data flow</title>
<description>Map data flows between applications</description>
<modules type="array">
<module>itop-flow-map</module>
</modules>
<default>false</default>
</choice>
</options>
</step>
<step>

View File

@@ -85,11 +85,13 @@ Dict::Add('CS CZ', 'Czech', 'Čeština', [
Dict::Add('CS CZ', 'Czech', 'Čeština', [
'Class:TriggerOnAttachmentDownload' => 'Trigger (on object\'s attachment download)~~',
'Class:TriggerOnAttachmentDownload+' => 'Trigger on object\'s attachment download of [a child class of] the given class~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -84,11 +84,13 @@ Dict::Add('DA DA', 'Danish', 'Dansk', [
Dict::Add('DA DA', 'Danish', 'Dansk', [
'Class:TriggerOnAttachmentDownload' => 'Trigger (on object\'s attachment download)~~',
'Class:TriggerOnAttachmentDownload+' => 'Trigger on object\'s attachment download of [a child class of] the given class~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -84,11 +84,13 @@ Dict::Add('DE DE', 'German', 'Deutsch', [
Dict::Add('DE DE', 'German', 'Deutsch', [
'Class:TriggerOnAttachmentDownload' => 'Trigger (beim Herunterladen eines Attachment eines Objekts)',
'Class:TriggerOnAttachmentDownload+' => 'Trigger für das Herunterladen des Attachments der angegebenen Klasse oder einer Unterklasse',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -91,11 +91,13 @@ Dict::Add('EN US', 'English', 'English', [
Dict::Add('EN US', 'English', 'English', [
'Class:TriggerOnAttachmentDownload' => 'Trigger (on object\'s attachment download)',
'Class:TriggerOnAttachmentDownload+' => 'Trigger on object\'s attachment download of [a child class of] the given class',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger',
]);

View File

@@ -81,11 +81,13 @@ Dict::Add('ES CR', 'Spanish', 'Español, Castellano', [
Dict::Add('ES CR', 'Spanish', 'Español, Castellano', [
'Class:TriggerOnAttachmentDownload' => 'Disparador (al descargar el archivo adjunto del objeto)',
'Class:TriggerOnAttachmentDownload+' => 'Disparador al descargar el archivo adjunto del objeto de [una clase secundaria de] la clase dada',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -89,5 +89,7 @@ Dict::Add('FR FR', 'French', 'Français', [
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'Si coché, le fichier sera automatiquement attaché à l\'email quand l\'action email est lancée',
'Class:TriggerOnAttachmentDelete' => 'Déclencheur sur la suppression d\'une pièce jointe',
'Class:TriggerOnAttachmentDelete+' => 'Déclencheur sur la suppression d\'une pièce jointe d\'un objet',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Ajoute le fichier supprimé dans l\'email',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Les Triggers sur les objets ne sont pas autorisés sur la classe Attachement. Veuillez utiliser les triggers spécifiques pour cette classe',
]);

View File

@@ -81,11 +81,13 @@ Dict::Add('HU HU', 'Hungarian', 'Magyar', [
Dict::Add('HU HU', 'Hungarian', 'Magyar', [
'Class:TriggerOnAttachmentDownload' => 'Trigger (on object\'s attachment download)~~',
'Class:TriggerOnAttachmentDownload+' => 'Trigger on object\'s attachment download of [a child class of] the given class~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -83,11 +83,13 @@ Dict::Add('IT IT', 'Italian', 'Italiano', [
Dict::Add('IT IT', 'Italian', 'Italiano', [
'Class:TriggerOnAttachmentDownload' => 'Trigger (al download di un allegato dell\'oggetto)',
'Class:TriggerOnAttachmentDownload+' => 'Trigger al download di un allegato di un oggetto di [una sottoclasse di] la classe data',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -83,11 +83,13 @@ Dict::Add('JA JP', 'Japanese', '日本語', [
Dict::Add('JA JP', 'Japanese', '日本語', [
'Class:TriggerOnAttachmentDownload' => 'Trigger (on object\'s attachment download)~~',
'Class:TriggerOnAttachmentDownload+' => 'Trigger on object\'s attachment download of [a child class of] the given class~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -91,5 +91,7 @@ Dict::Add('NL NL', 'Dutch', 'Nederlands', [
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (Bij het verwijderen van een bijlage)',
'Class:TriggerOnAttachmentDelete+' => 'Trigger bij het verwijderen van een bijlage van een object van de opgegeven klasse (of subklasse ervan)',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Bestand toevoegen in e-mail',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -83,11 +83,13 @@ Dict::Add('PL PL', 'Polish', 'Polski', [
Dict::Add('PL PL', 'Polish', 'Polski', [
'Class:TriggerOnAttachmentDownload' => 'Wyzwalacz (po pobraniu załącznika obiektu)',
'Class:TriggerOnAttachmentDownload+' => 'Wyzwalacz po pobraniu załącznika obiektu [klasy podrzędnej] danej klasy',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -83,11 +83,13 @@ Dict::Add('PT BR', 'Brazilian', 'Brazilian', [
Dict::Add('PT BR', 'Brazilian', 'Brazilian', [
'Class:TriggerOnAttachmentDownload' => 'Trigger (on object\'s attachment download)~~',
'Class:TriggerOnAttachmentDownload+' => 'Trigger on object\'s attachment download of [a child class of] the given class~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -84,11 +84,13 @@ Dict::Add('RU RU', 'Russian', 'Русский', [
Dict::Add('RU RU', 'Russian', 'Русский', [
'Class:TriggerOnAttachmentDownload' => 'Trigger (on object\'s attachment download)~~',
'Class:TriggerOnAttachmentDownload+' => 'Trigger on object\'s attachment download of [a child class of] the given class~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -83,11 +83,13 @@ Dict::Add('SK SK', 'Slovak', 'Slovenčina', [
Dict::Add('SK SK', 'Slovak', 'Slovenčina', [
'Class:TriggerOnAttachmentDownload' => 'Trigger (on object\'s attachment download)~~',
'Class:TriggerOnAttachmentDownload+' => 'Trigger on object\'s attachment download of [a child class of] the given class~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -83,11 +83,13 @@ Dict::Add('TR TR', 'Turkish', 'Türkçe', [
Dict::Add('TR TR', 'Turkish', 'Türkçe', [
'Class:TriggerOnAttachmentDownload' => 'Trigger (on object\'s attachment download)~~',
'Class:TriggerOnAttachmentDownload+' => 'Trigger on object\'s attachment download of [a child class of] the given class~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -83,11 +83,13 @@ Dict::Add('ZH CN', 'Chinese', '简体中文', [
Dict::Add('ZH CN', 'Chinese', '简体中文', [
'Class:TriggerOnAttachmentDownload' => '触发器 (于对象附件下载时)',
'Class:TriggerOnAttachmentDownload+' => '触发器于指定类型 [子类型] 对象附件下载时',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment create)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment create~~',
'Class:TriggerOnAttachmentCreate' => 'Trigger (on object\'s attachment creation)~~',
'Class:TriggerOnAttachmentCreate+' => 'Trigger on object\'s attachment creation~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email' => 'Add file in email~~',
'Class:TriggerOnAttachmentCreate/Attribute:file_in_email+' => 'If checked, the file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment delete)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment delete~~',
'Class:TriggerOnAttachmentDelete' => 'Trigger (on object\'s attachment deletion)~~',
'Class:TriggerOnAttachmentDelete+' => 'Trigger on object\'s attachment deletion~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email' => 'Add deleted file in email~~',
'Class:TriggerOnAttachmentDelete/Attribute:file_in_email+' => 'If checked, the deleted file will be automatically attached to the email when an email action is triggered~~',
'Class:TriggerOnObject:TriggerClassAttachment/ReadOnlyMessage' => 'Trigger on object is not allowed on class Attachment. Please use specific trigger~~',
]);

View File

@@ -34,9 +34,11 @@ class TriggerOnAttachmentDelete extends TriggerOnObject
];
MetaModel::Init_Params($aParams);
MetaModel::Init_InheritAttributes();
MetaModel::Init_AddAttribute(new AttributeBoolean("file_in_email", ["sql" => 'file_in_email', "is_null_allowed" => false, "default_value" => 'true', "allowed_values" => null, "depends_on" => [], "always_load_in_tables" => false]));
// Display lists
MetaModel::Init_SetZListItems('details', ['description', 'context', 'filter', 'action_list', 'target_class']); // Attributes to be displayed for the complete details
MetaModel::Init_SetZListItems('details', ['description', 'context', 'filter', 'action_list', 'target_class','file_in_email']); // Attributes to be displayed for the complete details
MetaModel::Init_SetZListItems('list', ['finalclass', 'target_class']); // Attributes to be displayed for a list
// Search criteria
MetaModel::Init_SetZListItems('standard_search', ['description', 'target_class']); // Criteria of the std search form

View File

@@ -167,7 +167,7 @@ Dict::Add('EN US', 'English', 'English', [
Dict::Add('EN US', 'English', 'English', [
'Class:Rack' => 'Rack',
'Class:Rack+' => 'A physical cabinet for Datacenter Devices and Chassis.',
'Class:Rack+' => 'A physical cabinet for Datacenter Devices and Enclosures.',
'Class:Rack/ComplementaryName' => '%1$s - %2$s',
'Class:Rack/Attribute:nb_u' => 'Rack units',
'Class:Rack/Attribute:nb_u+' => '',

View File

@@ -1480,19 +1480,19 @@
<cell id="1" _delta="must_exist">
<rank>1</rank>
<dashlets>
<dashlet id="container_43" xsi:type="DashletBadge" _delta="define">
<dashlet id="ContainerApplication" xsi:type="DashletBadge" _delta="define">
<rank>5</rank>
<class>ContainerApplication</class>
</dashlet>
<dashlet id="container_44" xsi:type="DashletBadge" _delta="define">
<dashlet id="ContainerHost" xsi:type="DashletBadge" _delta="define">
<rank>6</rank>
<class>ContainerHost</class>
</dashlet>
<dashlet id="container_45" xsi:type="DashletBadge" _delta="define">
<dashlet id="ContainerCluster" xsi:type="DashletBadge" _delta="define">
<rank>7</rank>
<class>ContainerCluster</class>
</dashlet>
<dashlet id="container_46" xsi:type="DashletBadge" _delta="define">
<dashlet id="ContainerImage" xsi:type="DashletBadge" _delta="define">
<rank>8</rank>
<class>ContainerImage</class>
</dashlet>
@@ -1507,11 +1507,11 @@
<cell id="0">
<rank>0</rank>
<dashlets>
<dashlet id="container_21" xsi:type="DashletBadge" _delta="define">
<dashlet id="ContainerType" xsi:type="DashletBadge" _delta="define">
<rank>21</rank>
<class>ContainerType</class>
</dashlet>
<dashlet id="container_22" xsi:type="DashletBadge" _delta="define">
<dashlet id="ContainerImageType" xsi:type="DashletBadge" _delta="define">
<rank>22</rank>
<class>ContainerImageType</class>
</dashlet>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<Set>
<DataFlowType alias="DataFlowType" id="1">
<name>http</name>
</DataFlowType>
<DataFlowType alias="DataFlowType" id="2">
<name>https</name>
</DataFlowType>
<DataFlowType alias="DataFlowType" id="3">
<name>ftp</name>
</DataFlowType>
<DataFlowType alias="DataFlowType" id="4">
<name>sftp</name>
</DataFlowType>
<DataFlowType alias="DataFlowType" id="5">
<name>AS2</name>
</DataFlowType>
<DataFlowType alias="DataFlowType" id="6">
<name>X.400</name>
</DataFlowType>
</Set>

View File

@@ -0,0 +1,615 @@
<?xml version="1.0" encoding="UTF-8"?>
<itop_design xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="3.3">
<classes>
<class id="DataFlow" _delta="define">
<parent>FunctionalCI</parent>
<properties>
<category>bizmodel,searchable</category>
<abstract>false</abstract>
<db_table>dataflow</db_table>
<style>
<icon>images/icons8-sorting-arrows-horizontal.svg</icon>
</style>
<naming>
<attributes>
<attribute id="name"/>
</attributes>
<complementary_attributes>
<attribute id="source_id"/>
<attribute id="destination_id"/>
</complementary_attributes>
</naming>
<reconciliation>
<attributes>
<attribute id="name"/>
<attribute id="destination_id"/>
<attribute id="org_id"/>
<attribute id="source_id"/>
<attribute id="dataflowtype_id"/>
</attributes>
</reconciliation>
<obsolescence>
<condition>status='inactive'</condition>
</obsolescence>
<fields_semantic>
<state_attribute>status</state_attribute>
</fields_semantic>
</properties>
<fields>
<field id="source_id" xsi:type="AttributeExternalKey">
<sql>source_id</sql>
<filter><![CDATA[SELECT FunctionalCI WHERE finalclass != 'DataFlow']]></filter>
<dependencies/>
<is_null_allowed>false</is_null_allowed>
<target_class>FunctionalCI</target_class>
<on_target_delete>DEL_MANUAL</on_target_delete>
<tracking_level>all</tracking_level>
</field>
<field id="source_impact" xsi:type="AttributeEnum">
<sort_type>rank</sort_type>
<values>
<value id="yes">
<code>yes</code>
<rank>10</rank>
</value>
<value id="no">
<code>no</code>
<rank>20</rank>
</value>
</values>
<sql>source_impact</sql>
<default_value>yes</default_value>
<is_null_allowed>false</is_null_allowed>
<display_style>radio_horizontal</display_style>
</field>
<field id="destination_id" xsi:type="AttributeExternalKey">
<sql>destination_id</sql>
<filter><![CDATA[SELECT FunctionalCI WHERE finalclass != 'DataFlow']]></filter>
<dependencies/>
<is_null_allowed>false</is_null_allowed>
<target_class>FunctionalCI</target_class>
<on_target_delete>DEL_MANUAL</on_target_delete>
<tracking_level>all</tracking_level>
</field>
<field id="destination_impact" xsi:type="AttributeEnum">
<sort_type>rank</sort_type>
<values>
<value id="yes">
<code>yes</code>
<rank>10</rank>
</value>
<value id="no">
<code>no</code>
<rank>20</rank>
</value>
</values>
<sql>destination_impact</sql>
<default_value>no</default_value>
<is_null_allowed>false</is_null_allowed>
<display_style>radio_horizontal</display_style>
</field>
<field id="dataflowtype_id" xsi:type="AttributeExternalKey">
<sql>dataflowtype_id</sql>
<filter/>
<dependencies/>
<is_null_allowed>true</is_null_allowed>
<target_class>DataFlowType</target_class>
<on_target_delete>DEL_MANUAL</on_target_delete>
<tracking_level>all</tracking_level>
</field>
<field id="status" xsi:type="AttributeEnum">
<sql>status</sql>
<values>
<value id="active">
<code>active</code>
<rank>10</rank>
<style>
<main_color>$ibo-lifecycle-active-state-primary-color</main_color>
<complementary_color>$ibo-lifecycle-active-state-secondary-color</complementary_color>
<decoration_classes/>
</style>
</value>
<value id="inactive">
<code>inactive</code>
<rank>20</rank>
<style>
<main_color>$ibo-lifecycle-inactive-state-primary-color</main_color>
<complementary_color>$ibo-lifecycle-inactive-state-secondary-color</complementary_color>
<decoration_classes/>
</style>
</value>
</values>
<sort_type>label</sort_type>
<default_value>active</default_value>
<is_null_allowed>false</is_null_allowed>
<display_style>list</display_style>
<tracking_level>all</tracking_level>
</field>
<field id="execution_frequency" xsi:type="AttributeEnum">
<sort_type>rank</sort_type>
<values>
<value id="realtime">
<code>realtime</code>
<rank>10</rank>
</value>
<value id="ondemand">
<code>ondemand</code>
<rank>20</rank>
</value>
<value id="hourly">
<code>hourly</code>
<rank>30</rank>
</value>
<value id="daily">
<code>daily</code>
<rank>40</rank>
</value>
<value id="weekly">
<code>weekly</code>
<rank>50</rank>
</value>
<value id="monthly">
<code>monthly</code>
<rank>60</rank>
</value>
<value id="yearly">
<code>yearly</code>
<rank>70</rank>
</value>
</values>
<sql>execution_frequency</sql>
<default_value>daily</default_value>
<is_null_allowed>false</is_null_allowed>
<display_style>list</display_style>
</field>
</fields>
<event_listeners>
<event_listener id="evtCheckSourceAndDestination">
<event>EVENT_DB_CHECK_TO_WRITE</event>
<rank>10</rank>
<callback>evtCheckSourceAndDestination</callback>
</event_listener>
</event_listeners>
<methods>
<method id="evtCheckSourceAndDestination" _delta="define">
<comment> /**
* Ensure that the source and destination of a data flow are not DataFlow themselves
*
*/</comment>
<static>false</static>
<access>public</access>
<type>EventListener</type>
<code><![CDATA[ public function evtCheckSourceAndDestination(Combodo\iTop\Service\Events\EventData $oEventData)
{
$oSource = MetaModel::GetObject(FunctionalCI::class, $this->Get('source_id'), false, true);
$oDestination = MetaModel::GetObject(FunctionalCI::class, $this->Get('destination_id'), false, true);
if ($oSource instanceof DataFlow) {
$this->AddCheckIssue(Dict::Format('Class:DataFlow/Error:CheckSource', $oSource->GetName()));
}
if ($oDestination instanceof DataFlow) {
$this->AddCheckIssue(Dict::Format('Class:DataFlow/Error:CheckDestination', $oDestination->GetName()));
}
} ]]></code>
</method>
</methods>
<presentation>
<list>
<items>
<item id="name">
<rank>10</rank>
</item>
<item id="source_id">
<rank>20</rank>
</item>
<item id="destination_id">
<rank>30</rank>
</item>
<item id="dataflowtype_id">
<rank>40</rank>
</item>
<item id="business_criticity">
<rank>50</rank>
</item>
</items>
</list>
<search>
<items>
<item id="org_id">
<rank>10</rank>
</item>
<item id="source_id">
<rank>20</rank>
</item>
<item id="destination_id">
<rank>30</rank>
</item>
<item id="status">
<rank>40</rank>
</item>
</items>
</search>
<details>
<items>
<item id="col:col1">
<items>
<item id="fieldset:ConfigMgmt:baseinfo">
<items>
<item id="name">
<rank>10</rank>
</item>
<item id="org_id">
<rank>20</rank>
</item>
<item id="status">
<rank>30</rank>
</item>
<item id="business_criticity">
<rank>40</rank>
</item>
</items>
<rank>10</rank>
</item>
<item id="fieldset:DataFlow:moreinfo">
<items>
<item id="source_id">
<rank>10</rank>
</item>
<item id="source_impact">
<rank>20</rank>
</item>
<item id="destination_id">
<rank>30</rank>
</item>
<item id="destination_impact">
<rank>40</rank>
</item>
<item id="dataflowtype_id">
<rank>50</rank>
</item>
<item id="execution_frequency">
<rank>60</rank>
</item>
</items>
<rank>20</rank>
</item>
</items>
<rank>10</rank>
</item>
<item id="col:col2">
<items>
<item id="fieldset:ConfigMgmt:dates">
<items>
<item id="move2production">
<rank>10</rank>
</item>
</items>
<rank>10</rank>
</item>
<item id="fieldset:ConfigMgmt:otherinfo">
<items>
<item id="description">
<rank>10</rank>
</item>
<item id="groups_list">
<rank>20</rank>
</item>
</items>
<rank>20</rank>
</item>
</items>
<rank>20</rank>
</item>
<item id="contacts_list">
<rank>70</rank>
</item>
<item id="documents_list">
<rank>80</rank>
</item>
</items>
</details>
<default_search>
<items>
<item id="org_id">
<rank>10</rank>
</item>
<item id="source_id">
<rank>20</rank>
</item>
<item id="destination_id">
<rank>30</rank>
</item>
<item id="dataflowtype_id">
<rank>40</rank>
</item>
<item id="status">
<rank>50</rank>
</item>
</items>
</default_search>
<summary>
<items>
<item id="business_criticity">
<rank>10</rank>
</item>
<item id="source_id">
<rank>20</rank>
</item>
<item id="destination_id">
<rank>30</rank>
</item>
<item id="execution_frequency">
<rank>40</rank>
</item>
</items>
</summary>
</presentation>
<relations>
<relation id="impacts">
<neighbours>
<neighbour id="functionalci">
<query_down><![CDATA[SELECT FunctionalCI WHERE :this->destination_impact = 'yes' AND id = :this->destination_id]]></query_down>
<query_up><![CDATA[SELECT DataFlow AS f JOIN FunctionalCI AS ci ON f.destination_id = ci.id WHERE f.destination_impact = 'yes' AND ci.id=:this->id]]></query_up>
<direction>both</direction>
</neighbour>
<neighbour id="contact">
<attribute>contacts_list</attribute>
<direction>down</direction>
</neighbour>
</neighbours>
</relation>
<relation id="dataflows">
<neighbours>
<neighbour id="functionalci">
<query_down><![CDATA[SELECT FunctionalCI WHERE id = :this->destination_id]]></query_down>
<query_up><![CDATA[SELECT DataFlow AS f WHERE f.destination_id = :this->id]]></query_up>
<direction>both</direction>
</neighbour>
</neighbours>
</relation>
</relations>
</class>
<class id="DataFlowType" _delta="define">
<parent>Typology</parent>
<properties>
<category>bizmodel,searchable</category>
<abstract>false</abstract>
<db_table>dataflowtype</db_table>
<naming>
<attributes>
<attribute id="name"/>
</attributes>
</naming>
<reconciliation>
<attributes>
<attribute id="name"/>
<attribute id="finalclass"/>
</attributes>
</reconciliation>
<uniqueness_rules>
<rule id="name">
<attributes>
<attribute id="name"/>
</attributes>
<filter><![CDATA[]]></filter>
<disabled>false</disabled>
<is_blocking>true</is_blocking>
</rule>
</uniqueness_rules>
</properties>
<fields/>
<methods/>
<presentation>
<list>
<items>
<item id="finalclass">
<rank>10</rank>
</item>
</items>
</list>
<search>
<items>
<item id="name">
<rank>10</rank>
</item>
</items>
</search>
<details>
<items>
<item id="name">
<rank>10</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="FunctionalCI" _delta="must_exist">
<fields>
<field id="dataflows" xsi:type="AttributeDashboard" _delta="define">
<is_user_editable>true</is_user_editable>
<definition>
<layout>DashboardLayoutTwoCols</layout>
<title>FunctionalCI:DataFlow:Title</title>
<auto_reload>
<enabled>false</enabled>
<interval>300</interval>
</auto_reload>
<cells>
<cell id="0">
<rank>0</rank>
<dashlets>
<dashlet id="DataFlow_Inbound" xsi:type="DashletObjectList">
<rank>0</rank>
<title>FunctionalCI:DataFlow:Inbound</title>
<query>SELECT DataFlow WHERE destination_id=:this->id</query>
<menu>true</menu>
</dashlet>
</dashlets>
</cell>
<cell id="1">
<rank>1</rank>
<dashlets>
<dashlet id="DataFlow_Outbound" xsi:type="DashletObjectList">
<rank>0</rank>
<title>FunctionalCI:DataFlow:Outbound</title>
<query>SELECT DataFlow WHERE source_id=:this->id</query>
<menu>true</menu>
</dashlet>
</dashlets>
</cell>
</cells>
</definition>
</field>
</fields>
<relations>
<relation id="impacts">
<neighbours>
<neighbour id="flow" _delta="define">
<query_down><![CDATA[SELECT DataFlow WHERE source_id = :this->id AND source_impact = 'yes']]></query_down>
<query_up><![CDATA[SELECT FunctionalCI AS ci JOIN DataFlow AS f ON f.source_id = ci.id WHERE f.source_impact = 'yes' AND f.id = :this->id]]></query_up>
<direction>both</direction>
</neighbour>
</neighbours>
</relation>
<relation id="dataflows" _delta="define">
<neighbours>
<neighbour id="flow">
<query_down><![CDATA[SELECT DataFlow WHERE source_id = :this->id]]></query_down>
<query_up><![CDATA[SELECT FunctionalCI AS ci JOIN DataFlow AS f ON f.source_id = ci.id WHERE f.id = :this->id]]></query_up>
<direction>both</direction>
</neighbour>
</neighbours>
</relation>
</relations>
</class>
<class id="ApplicationSolution" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="DatabaseSchema" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="DBServer" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="Middleware" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="MiddlewareInstance" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="WebApplication" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="WebServer" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="OtherSoftware" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
</classes>
<menus>
<menu id="ConfigManagementOverview" xsi:type="DashboardMenuNode" _delta="must_exist">
<definition>
<cells>
<cell id="3" delta="if_exists">
<dashlets>
<dashlet id="DataFlow" xsi:type="DashletBadge" _delta="define">
<rank>20</rank>
<class>DataFlow</class>
</dashlet>
</dashlets>
</cell>
</cells>
</definition>
</menu>
<menu id="Typology" xsi:type="DashboardMenuNode" _delta="must_exist">
<definition>
<cells>
<cell id="0">
<rank>0</rank>
<dashlets>
<dashlet id="DataFlowType" xsi:type="DashletBadge" _delta="define">
<rank>23</rank>
<class>DataFlowType</class>
</dashlet>
</dashlets>
</cell>
</cells>
</definition>
</menu>
</menus>
<user_rights>
<groups>
<group id="Configuration">
<classes>
<class id="DataFlow" _delta="define_if_not_exists"/>
</classes>
</group>
</groups>
<profiles>
</profiles>
</user_rights>
</itop_design>

View File

@@ -0,0 +1,91 @@
<?php
/**
* Module combodo-flow-map
*
* @copyright Copyright (C) 2013 XXXXX
* @license http://opensource.org/licenses/AGPL-3.0
*/
Dict::Add('EN US', 'English', 'English', [
'Relation:dataflows/Description' => 'DataFlows between CIs',
'Relation:dataflows/DownStream' => 'Outbound flows...',
'Relation:dataflows/DownStream+' => 'Outbound flows map from',
'Relation:dataflows/UpStream' => 'Inbound flows...',
'Relation:dataflows/UpStream+' => 'Inbound flows map to',
'Class:FunctionalCI/Attribute:dataflows' => 'Data flows',
'Class:FunctionalCI/Attribute:dataflows+' => 'Data flows for which this object is the source or the destination',
'FunctionalCI:DataFlow:Title' => 'Data flows',
'FunctionalCI:DataFlow:Inbound' => 'Inbound flows',
'FunctionalCI:DataFlow:Outbound' => 'Outbound flows',
'DataFlow:moreinfo' => 'Flow specifics',
'Class:DataFlow' => 'Flow',
'Class:DataFlow+' => 'For application flow for example',
'Class:DataFlow/ComplementaryName' => '%1$s - %2$s',
'Class:DataFlow/Attribute:name' => 'Name',
'Class:DataFlow/Attribute:name+' => 'Identify the transferred data flow',
'Class:DataFlow/Attribute:source_id' => 'Source',
'Class:DataFlow/Attribute:source_id+' => 'Source Ci of the flow',
'Class:DataFlow/Attribute:source_impact' => 'Source impacts?',
'Class:DataFlow/Attribute:source_impact+' => 'Does the source impact the flow?',
'Class:DataFlow/Attribute:source_impact/Value:yes' => 'yes',
'Class:DataFlow/Attribute:source_impact/Value:yes+' => 'If the source falls down, the flow is impacted',
'Class:DataFlow/Attribute:source_impact/Value:no' => 'no',
'Class:DataFlow/Attribute:source_impact/Value:no+' => 'If the source falls down, the flow is not impacted',
'Class:DataFlow/Attribute:destination_id' => 'Destination',
'Class:DataFlow/Attribute:destination_id+' => 'Destination Ci for the flow',
'Class:DataFlow/Attribute:destination_impact' => 'Destination impacted',
'Class:DataFlow/Attribute:destination_impact+' => 'Is the destination impacted by the flow ?',
'Class:DataFlow/Attribute:destination_impact/Value:yes' => 'yes',
'Class:DataFlow/Attribute:destination_impact/Value:yes+' => 'If the flow stops, the destination is impacted',
'Class:DataFlow/Attribute:destination_impact/Value:no' => 'no',
'Class:DataFlow/Attribute:destination_impact/Value:no+' => 'If the flow stops, the destination is not impacted',
'Class:DataFlow/Attribute:dataflowtype_id' => 'Flow type',
'Class:DataFlow/Attribute:dataflowtype_id+' => 'Typology of Flow.',
'Class:DataFlow/Attribute:status' => 'Status',
'Class:DataFlow/Attribute:status+' => '',
'Class:DataFlow/Attribute:status/Value:active' => 'active',
'Class:DataFlow/Attribute:status/Value:inactive' => 'inactive',
'Class:DataFlow/Attribute:execution_frequency' => 'Execution frequency',
'Class:DataFlow/Attribute:execution_frequency+' => 'How often the data flow is executed',
'Class:DataFlow/Attribute:execution_frequency/Value:realtime' => 'real-time',
'Class:DataFlow/Attribute:execution_frequency/Value:realtime+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:ondemand' => 'on demand',
'Class:DataFlow/Attribute:execution_frequency/Value:ondemand+' => 'on the fly, not scheduled',
'Class:DataFlow/Attribute:execution_frequency/Value:hourly' => 'hourly',
'Class:DataFlow/Attribute:execution_frequency/Value:hourly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:daily' => 'daily',
'Class:DataFlow/Attribute:execution_frequency/Value:daily+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:weekly' => 'weekly',
'Class:DataFlow/Attribute:execution_frequency/Value:weekly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:monthly' => 'monthly',
'Class:DataFlow/Attribute:execution_frequency/Value:monthly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:yearly' => 'yearly',
'Class:DataFlow/Attribute:execution_frequency/Value:yearly+' => '',
'Class:DataFlow/Attribute:documents_list+' => 'Eg: technical specifications, runbooks, etc.',
'Class:DataFlow/Attribute:contacts_list+' => 'Eg: flow owner, technical support, etc.',
'Class:DataFlow/Error:CheckSource' => 'The source of a data flow cannot be a data flow itself. Choose another source CI than %1$s',
'Class:DataFlow/Error:CheckDestination' => 'The destination of a data flow cannot be a data flow itself. Choose another destination CI than %1$s',
'Class:DataFlowType' => 'Data Flow Type',
'Class:DataFlowType+' => 'Typology of Data Flow',
/*
'Class:DataFlow/Attribute:source_id_friendlyname' => 'source_id_friendlyname',
'Class:DataFlow/Attribute:source_id_friendlyname+' => 'Full name',
'Class:DataFlow/Attribute:source_id_finalclass_recall' => 'source_id->CI sub-class',
'Class:DataFlow/Attribute:source_id_finalclass_recall+' => 'Name of the final class',
'Class:DataFlow/Attribute:source_id_obsolescence_flag' => 'source_id->Obsolete',
'Class:DataFlow/Attribute:source_id_obsolescence_flag+' => 'Computed dynamically on other attributes',
'Class:DataFlow/Attribute:destination_id_friendlyname' => 'destination_id_friendlyname',
'Class:DataFlow/Attribute:destination_id_friendlyname+' => 'Full name',
'Class:DataFlow/Attribute:destination_id_finalclass_recall' => 'destination_id->CI sub-class',
'Class:DataFlow/Attribute:destination_id_finalclass_recall+' => 'Name of the final class',
'Class:DataFlow/Attribute:destination_id_obsolescence_flag' => 'destination_id->Obsolete',
'Class:DataFlow/Attribute:destination_id_obsolescence_flag+' => 'Computed dynamically on other attributes',
*/
]);

View File

@@ -0,0 +1,91 @@
<?php
/**
* Module combodo-flow-map
*
* @copyright Copyright (C) 2013 XXXXX
* @license http://opensource.org/licenses/AGPL-3.0
*/
Dict::Add('FR FR', 'French', 'Français', [
'Relation:dataflows/Description' => 'Flux de données entre CIs',
'Relation:dataflows/DownStream' => 'Flux sortants...',
'Relation:dataflows/DownStream+' => 'Carte des flux sortants depuis',
'Relation:dataflows/UpStream' => 'Flux entrants...',
'Relation:dataflows/UpStream+' => 'Carte des flux entrants vers',
'Class:FunctionalCI/Attribute:dataflows' => 'Flux de données',
'Class:FunctionalCI/Attribute:dataflows+' => 'Flux de données dont cet objet est la source ou la destination',
'FunctionalCI:DataFlow:Title' => 'Flux de données',
'FunctionalCI:DataFlow:Inbound' => 'Flux entrants',
'FunctionalCI:DataFlow:Outbound' => 'Flux sortants',
'DataFlow:moreinfo' => 'Spécificités du flux',
'Class:DataFlow' => 'Flux de Données',
'Class:DataFlow+' => 'Modélise les données transférées entre instances d\'application ou plus généralement entre CIs.',
'Class:DataFlow/ComplementaryName' => '%1$s - %2$s',
'Class:DataFlow/Attribute:name' => 'Nom',
'Class:DataFlow/Attribute:name+' => 'Identifie le flux de données',
'Class:DataFlow/Attribute:source_id' => 'Source',
'Class:DataFlow/Attribute:source_id+' => 'Instance d\application à la source du flux de données',
'Class:DataFlow/Attribute:source_impact' => 'Source impactante ?',
'Class:DataFlow/Attribute:source_impact+' => 'La source impacte-t-elle le flux de données ?',
'Class:DataFlow/Attribute:source_impact/Value:yes' => 'oui',
'Class:DataFlow/Attribute:source_impact/Value:yes+' => 'Si la source tombe en panne, le flux de données est impacté',
'Class:DataFlow/Attribute:source_impact/Value:no' => 'non',
'Class:DataFlow/Attribute:source_impact/Value:no+' => 'Si la source tombe en panne, le flux de données n\'est pas impacté',
'Class:DataFlow/Attribute:destination_id' => 'Destinataire',
'Class:DataFlow/Attribute:destination_id+' => 'Destinataire des données, à choisir parmi les instances d\'application',
'Class:DataFlow/Attribute:destination_impact' => 'Destinataire impacté ?',
'Class:DataFlow/Attribute:destination_impact+' => 'Le destinataire est-il impacté si le flux de données s\'arrête ?',
'Class:DataFlow/Attribute:destination_impact/Value:yes' => 'oui',
'Class:DataFlow/Attribute:destination_impact/Value:yes+' => 'Si le flux s\'arrête, le destinataire est impacté',
'Class:DataFlow/Attribute:destination_impact/Value:no' => 'non',
'Class:DataFlow/Attribute:destination_impact/Value:no+' => 'Si le flux s\'arrête, le destinataire n\'est pas impacté',
'Class:DataFlow/Attribute:dataflowtype_id' => 'Type de flux',
'Class:DataFlow/Attribute:dataflowtype_id+' => 'Typologie du flux',
'Class:DataFlow/Attribute:status' => 'Etat',
'Class:DataFlow/Attribute:status+' => '',
'Class:DataFlow/Attribute:status/Value:active' => 'actif',
'Class:DataFlow/Attribute:status/Value:inactive' => 'inactif',
'Class:DataFlow/Attribute:execution_frequency' => 'Fréquence d\'exécution',
'Class:DataFlow/Attribute:execution_frequency+' => 'À quelle fréquence le transfert de données est-il exécuté',
'Class:DataFlow/Attribute:execution_frequency/Value:realtime' => 'temps réel',
'Class:DataFlow/Attribute:execution_frequency/Value:realtime+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:ondemand' => 'à la demande',
'Class:DataFlow/Attribute:execution_frequency/Value:ondemand+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:hourly' => 'horaire',
'Class:DataFlow/Attribute:execution_frequency/Value:hourly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:daily' => 'journalière',
'Class:DataFlow/Attribute:execution_frequency/Value:daily+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:weekly' => 'hebdomadaire',
'Class:DataFlow/Attribute:execution_frequency/Value:weekly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:monthly' => 'mensuelle',
'Class:DataFlow/Attribute:execution_frequency/Value:monthly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:yearly' => 'annuelle',
'Class:DataFlow/Attribute:execution_frequency/Value:yearly+' => '',
'Class:DataFlow/Attribute:documents_list+' => 'Eg: technical specifications, runbooks, etc.',
'Class:DataFlow/Attribute:contacts_list+' => 'Eg: flow owner, technical support, etc.',
'Class:DataFlow/Error:CheckSource' => 'La source d\'un flux de données ne peut pas être un flux de données elle-même. Choisissez un autre CI source que %1$s',
'Class:DataFlow/Error:CheckDestination' => 'La destination d\'un flux de données ne peut pas être un flux de données elle-même. Choisissez un autre CI destination que %1$s',
'Class:DataFlowType' => 'Type de flux',
'Class:DataFlowType+' => 'Typologie des flux de données',
/*
'Class:DataFlow/Attribute:source_id_friendlyname' => 'source_id_friendlyname',
'Class:DataFlow/Attribute:source_id_friendlyname+' => 'Nom complet',
'Class:DataFlow/Attribute:source_id_finalclass_recall' => 'source_id->CI sub-class',
'Class:DataFlow/Attribute:source_id_finalclass_recall+' => 'Classe finale',
'Class:DataFlow/Attribute:source_id_obsolescence_flag' => 'source_id->Obsolete',
'Class:DataFlow/Attribute:source_id_obsolescence_flag+' => 'Computed dynamically on other attributes',
'Class:DataFlow/Attribute:destination_id_friendlyname' => 'destination_id_friendlyname',
'Class:DataFlow/Attribute:destination_id_friendlyname+' => 'Nom complet',
'Class:DataFlow/Attribute:destination_id_finalclass_recall' => 'destination_id->CI sub-class',
'Class:DataFlow/Attribute:destination_id_finalclass_recall+' => 'Classe finale',
'Class:DataFlow/Attribute:destination_id_obsolescence_flag' => 'destination_id->Obsolete',
'Class:DataFlow/Attribute:destination_id_obsolescence_flag+' => 'Computed dynamically on other attributes',
*/
]);

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="mv_DwPz_GcV~datTQ_sP3a" x1="27.258" x2="38.501" y1="18.189" y2="44.314" gradientTransform="rotate(90 23.5 24)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3a)" d="M14,41.19V37h14c0.552,0,1-0.448,1-1v-4c0-0.552-0.448-1-1-1H14v-4.19 c0-0.72-0.87-1.08-1.379-0.571L5.92,32.939c-0.586,0.586-0.586,1.536,0,2.121l6.701,6.701C13.13,42.271,14,41.91,14,41.19z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3b" x1="32.674" x2="34.456" y1="9.581" y2="13.722" gradientTransform="rotate(90 23.5 24)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3b)" d="M35,36v-4c0-0.552,0.448-1,1-1l0,0c0.552,0,1,0.448,1,1v4c0,0.552-0.448,1-1,1l0,0 C35.448,37,35,36.552,35,36z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3c" x1="32.674" x2="34.456" y1="5.581" y2="9.722" gradientTransform="rotate(90 23.5 24)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3c)" d="M39,36v-4c0-0.552,0.448-1,1-1l0,0c0.552,0,1,0.448,1,1v4c0,0.552-0.448,1-1,1l0,0 C39.448,37,39,36.552,39,36z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3d" x1="32.674" x2="34.456" y1="13.581" y2="17.722" gradientTransform="rotate(90 23.5 24)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3d)" d="M31,36v-4c0-0.552,0.448-1,1-1h0c0.552,0,1,0.448,1,1v4c0,0.552-0.448,1-1,1h0 C31.448,37,31,36.552,31,36z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3e" x1="551.258" x2="562.501" y1="-252.291" y2="-226.167" gradientTransform="rotate(-90 421.24 151.26)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1ea2e4"/><stop offset="1" stop-color="#32bdef"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3e)" d="M33,7.81V12H19c-0.552,0-1,0.448-1,1v4c0,0.552,0.448,1,1,1h14v4.19 c0,0.72,0.87,1.08,1.379,0.571l6.701-6.701c0.586-0.586,0.586-1.536,0-2.121l-6.701-6.701C33.87,6.729,33,7.09,33,7.81z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3f" x1="556.674" x2="558.456" y1="-260.899" y2="-256.759" gradientTransform="rotate(-90 421.24 151.26)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1ea2e4"/><stop offset="1" stop-color="#32bdef"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3f)" d="M12,13v4c0,0.552-0.448,1-1,1h0c-0.552,0-1-0.448-1-1v-4c0-0.552,0.448-1,1-1h0 C11.552,12,12,12.448,12,13z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3g" x1="556.674" x2="558.456" y1="-264.899" y2="-260.759" gradientTransform="rotate(-90 421.24 151.26)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1ea2e4"/><stop offset="1" stop-color="#32bdef"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3g)" d="M8,13v4c0,0.552-0.448,1-1,1h0c-0.552,0-1-0.448-1-1v-4c0-0.552,0.448-1,1-1h0 C7.552,12,8,12.448,8,13z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3h" x1="556.674" x2="558.456" y1="-256.899" y2="-252.758" gradientTransform="rotate(-90 421.24 151.26)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1ea2e4"/><stop offset="1" stop-color="#32bdef"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3h)" d="M16,13v4c0,0.552-0.448,1-1,1h0c-0.552,0-1-0.448-1-1v-4c0-0.552,0.448-1,1-1h0 C15.552,12,16,12.448,16,13z"/></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,50 @@
<?php
//
// iTop module definition file
//
SetupWebPage::AddModule(
__FILE__, // Path to the current file, all other file names are relative to the directory containing this file
'itop-flow-map/3.3.0',
[
// Identification
//
'label' => 'Applications data flows',
'category' => 'business',
// Setup
//
'dependencies' => [
'itop-config-mgmt/3.2.0',
],
'mandatory' => false,
'visible' => true,
// Components
//
'datamodel' => [
],
'webservice' => [
],
'data.struct' => [
'data/en_us.data.itop-flow-map.xml',
],
'data.sample' => [
// add your sample data XML files here,
],
// Documentation
//
'doc.manual_setup' => '', // hyperlink to manual setup documentation, if any
'doc.more_information' => '', // hyperlink to more information, if any
// Default settings
//
'settings' => [
// Module specific settings go here, if any
],
]
);

View File

@@ -85,6 +85,21 @@
<class id="Attachment"/>
</classes>
</group>
<group id="Ticket" _delta="define">
<classes>
<class id="Ticket"/>
</classes>
</group>
<group id="FunctionalCI" _delta="define">
<classes>
<class id="FunctionalCI"/>
</classes>
</group>
<group id="ServiceFamily" _delta="define">
<classes>
<class id="ServiceFamily"/>
</classes>
</group>
<group id="Portal" _delta="define">
<classes>
<class id="lnkFunctionalCIToTicket"/>
@@ -205,6 +220,42 @@
</group>
</groups>
<profiles>
<profile id="5500" _delta="define">
<name>ReadOnlyCI</name>
<description>This read-only profile allows to see CIs objects.</description>
<groups>
<group id="FunctionalCI">
<actions>
<action id="action:read">allow</action>
<action id="action:bulk read">allow</action>
</actions>
</group>
</groups>
</profile>
<profile id="5501" _delta="define">
<name>ReadOnlyTicket</name>
<description>This read-only profile allows to see Ticket objects.</description>
<groups>
<group id="Ticket">
<actions>
<action id="action:read">allow</action>
<action id="action:bulk read">allow</action>
</actions>
</group>
</groups>
</profile>
<profile id="5502" _delta="define">
<name>ReadOnlyCatalog</name>
<description>This read-only profile allows to see ServiceFamily objects.</description>
<groups>
<group id="ServiceFamily">
<actions>
<action id="action:read">allow</action>
<action id="action:bulk read">allow</action>
</actions>
</group>
</groups>
</profile>
<profile id="117" _delta="define">
<name>SuperUser</name>
<description>This profile allows all actions which are not Administrator restricted.</description>

View File

@@ -216,6 +216,14 @@ Operators:<br/>
'Core:Context=GUI:Console' => 'Console',
'Core:Context=CRON' => 'cron',
'Core:Context=GUI:Portal' => 'Portal',
'Core:GetQuota:Error' => 'Error while getting %1$s quota',
'Core:ConsoleUsers' => 'console users',
'Core:DisabledUsers' => 'disabled users',
'Core:PortalUsers' => 'portal users',
'Core:BusinessPartnerUser' => 'business partner users',
'Core:ReadOnlyUsers' => 'read-only users',
'Core:ApplicationUsers' => 'application users',
]);
//////////////////////////////////////////////////////////////////////

View File

@@ -161,6 +161,14 @@ Opérateurs :<br/>
'Core:Context=CRON+' => 'cron',
'Core:Context=GUI:Portal' => 'Portal',
'Core:Context=GUI:Portal+' => 'GUI:Portal',
'Core:GetQuota:Error' => 'Erreur lors de la récupération du quota des %1$s',
'Core:ConsoleUsers' => 'utilisateurs console',
'Core:DisabledUsers' => 'utilisateurs désactivés',
'Core:PortalUsers' => 'utilisateurs du portail',
'Core:BusinessPartnerUser' => 'utilisateurs partenaires business',
'Core:ReadOnlyUsers' => 'utilisateurs en lecture seule',
'Core:ApplicationUsers' => 'utilisateurs applicatifs',
]);
//////////////////////////////////////////////////////////////////////

View File

@@ -643,6 +643,7 @@ return array(
'Combodo\\iTop\\SessionTracker\\SessionGC' => $baseDir . '/sources/SessionTracker/SessionGC.php',
'Combodo\\iTop\\SessionTracker\\SessionHandler' => $baseDir . '/sources/SessionTracker/SessionHandler.php',
'Combodo\\iTop\\SessionTracker\\iSessionHandlerExtension' => $baseDir . '/sources/SessionTracker/iSessionHandlerExtension.php',
'Combodo\\iTop\\Users\\ITopUserQuotaRepository' => $baseDir . '/sources/Users/ITopUserQuotaRepository.php',
'CompileCSSService' => $baseDir . '/application/compilecssservice.class.inc.php',
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'Config' => $baseDir . '/core/config.class.inc.php',

View File

@@ -1044,6 +1044,7 @@ class ComposerStaticInitfc0e9e9dea11dcbb6272414776c30685
'Combodo\\iTop\\SessionTracker\\SessionGC' => __DIR__ . '/../..' . '/sources/SessionTracker/SessionGC.php',
'Combodo\\iTop\\SessionTracker\\SessionHandler' => __DIR__ . '/../..' . '/sources/SessionTracker/SessionHandler.php',
'Combodo\\iTop\\SessionTracker\\iSessionHandlerExtension' => __DIR__ . '/../..' . '/sources/SessionTracker/iSessionHandlerExtension.php',
'Combodo\\iTop\\Users\\ITopUserQuotaRepository' => __DIR__ . '/../..' . '/sources/Users/ITopUserQuotaRepository.php',
'CompileCSSService' => __DIR__ . '/../..' . '/application/compilecssservice.class.inc.php',
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'Config' => __DIR__ . '/../..' . '/core/config.class.inc.php',

View File

@@ -153,8 +153,12 @@ class Extension
return twig_array_filter($oTwigEnv, $array, $arrow);
}, ['needs_environment' => true]);
// @since 3.3.0 N°8579
// Filter to remove spaces between HTML tags, overwrite the deprecated core "spaceless" filter
/**
* Filter to remove spaces between HTML tags, overwrite the deprecated core "spaceless" filter
* Usage in twig: {% apply spaceless %}some html{% endapply %}
*
* @since 3.2.3 3.3.0 N°8579
*/
$aFilters[] = new TwigFilter('spaceless', function (?string $content) {
return trim(preg_replace('/>\s+</', '><', $content ?? ''));
}, ['is_safe' => ['html']]);

View File

@@ -29,7 +29,9 @@ use Symfony\Component\CssSelector\Exception\ParseException;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mime\Email as SymfonyEmail;
use Symfony\Component\Mime\HtmlToTextConverter\DefaultHtmlToTextConverter;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\RelatedPart;
use Symfony\Component\Mime\Part\Multipart\MixedPart;
@@ -183,18 +185,7 @@ class EMailSymfony extends Email
$sDsn = sprintf('smtp://%s%s@%s%s', $sDsnUser, $sDsnPassword, $sDsnPort, $sEncQuery);
}
$oTransport = Transport::fromDsn($sDsn);
// Handle peer verification
$oStream = $oTransport->getStream();
$aOptions = $oStream->getStreamOptions();
if (!$bVerifyPeer && array_key_exists('ssl', $aOptions)) {
// Disable verification
$aOptions['ssl']['verify_peer'] = false;
$aOptions['ssl']['verify_peer_name'] = false;
$aOptions['ssl']['allow_self_signed'] = true;
}
$oStream->setStreamOptions($aOptions);
$oTransport = $this->CreateSmtpTransport($sDsn, $bVerifyPeer);
$oMailer = new Mailer($oTransport);
break;
@@ -260,6 +251,36 @@ class EMailSymfony extends Email
}
}
/**
* Build and configure an SMTP transport from a DSN string.
*
* Extracted from {@see SendSynchronous} to make SSL option handling independently testable.
* When $bVerifyPeer is false, the ssl stream context options must be written unconditionally:
* with STARTTLS the connection starts unencrypted, so the 'ssl' key is absent from the stream
* options at construction time and only used later when stream_socket_enable_crypto() is called.
*
* @param string $sDsn Full Symfony Mailer DSN (smtp:// or smtps://)
* @param bool $bVerifyPeer Whether to verify the peer SSL certificate
*
* @return \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport
*/
protected function CreateSmtpTransport(string $sDsn, bool $bVerifyPeer): EsmtpTransport
{
/** @var EsmtpTransport $oTransport */
$oTransport = Transport::fromDsn($sDsn);
$oStream = $oTransport->getStream();
$aOptions = $oStream->getStreamOptions();
if (!$bVerifyPeer) {
$aOptions['ssl']['verify_peer'] = false;
$aOptions['ssl']['verify_peer_name'] = false;
$aOptions['ssl']['allow_self_signed'] = true;
}
$oStream->setStreamOptions($aOptions);
return $oTransport;
}
/**
* Reprocess the body of the message (if it is an HTML message)
* to replace the URL of images based on attachments by a link
@@ -416,13 +437,13 @@ class EMailSymfony extends Email
$this->m_aData['body'] = ['body' => $sBody, 'mimeType' => $sMimeType];
$oTextPart = new TextPart(strip_tags($sBody), 'utf-8', 'plain', 'base64');
// Embed inline images and store them in attachments (so BuildSymfonyMessageFromInternal can pick them)
if ($sPrimaryMimeType === 'text/html') {
$aAdditionalParts = $this->EmbedInlineImages($sBody);
$oTextPart = new TextPart((new DefaultHtmlToTextConverter())->convert($sBody, 'utf-8'), 'utf-8', 'plain', 'base64');
$oHtmlPart = new TextPart($sBody, 'utf-8', 'html', 'base64');
$oAlternativePart = new AlternativePart($oHtmlPart, $oTextPart);
// It's important de order parts from least prefered to most prefered as per RFC 2046 {@see https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4}
$oAlternativePart = new AlternativePart($oTextPart, $oHtmlPart);
// Default root part is the HTML body
$oRootPart = $oAlternativePart;

View File

@@ -0,0 +1,221 @@
<?php
namespace Combodo\iTop\Users;
use CoreException;
use CoreUnexpectedValue;
use DBObjectSearch;
use DBObjectSet;
use DBSearch;
use DBUnionSearch;
use Dict;
use DictExceptionMissingString;
use Exception;
use IssueLog;
use MetaModel;
use MySQLException;
use User;
use UserRights;
/**
*
*/
class ITopUserQuotaRepository
{
/**
* @param string $sExcludedUsers
* @param string $sExcludedProfiles
* @param bool $bAllData
* @param string $sExcludedFinalClasses
*
* @return DBObjectSearch|DBUnionSearch|null
* @throws Exception
*/
public function GetConsoleUsers(string $sExcludedUsers = '', string $sExcludedProfiles = '', bool $bAllData = true, string $sExcludedFinalClasses = 'UserToken, UserRemoteSaaS'): null|DBObjectSearch|DBUnionSearch
{
$sOQLInQuotaUser = "
SELECT User AS u
WHERE u.status != 'disabled'
AND u.login NOT IN ('$sExcludedUsers')
AND u.finalclass != ' $sExcludedFinalClasses '
AND id NOT IN (
SELECT User AS uex
JOIN URP_UserProfile AS uup ON uup.userid = uex.id
JOIN URP_Profiles AS up ON uup.profileid = up.id
WHERE up.name IN ('$sExcludedProfiles'))
";
try {
$oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLInQuotaUser) : DBObjectSearch::FromOQL($sOQLInQuotaUser);
}
catch (Exception $e) {
IssueLog::Error('Core:GetConsoleUsersQuota:Error : '.$e->getMessage());
throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:ConsoleUsers')));
}
// TODO remove read only users
return $oFilter;
}
/**
* @throws Exception
*/
public function GetApplicationUsers(bool $bAllData = true): null|DBObjectSearch|DBUnionSearch
{
$sOQLApplicationUser = 'SELECT UserToken';
try {
$oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLApplicationUser) : DBObjectSearch::FromOQL($sOQLApplicationUser);
}
catch (Exception $e) {
IssueLog::Error('Core:GetConsoleUsersQuota:Error : '.$e->getMessage());
throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:ApplicationUsers')));
}
return $oFilter;
}
/**
* @throws Exception
*/
public function GetDisabledUsers(bool $bAllData = true): null|DBObjectSearch|DBUnionSearch
{
$sOQLDisabledUser = "
SELECT User AS u
WHERE u.status = 'disabled'
";
try {
$oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLDisabledUser) : DBObjectSearch::FromOQL($sOQLDisabledUser);
}
catch (Exception $e) {
IssueLog::Error('Core:GetDisabledUsersQuota:Error : '.$e->getMessage());
throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:DisabledUsers')));
}
return $oFilter;
}
private function IsUserReadOnly(User $oUser, string $sClassCategory)
{
UserRights::Login($oUser->GetName());
foreach (MetaModel::GetClasses($sClassCategory) as $sClass) {
$aClassStimuli = MetaModel::EnumStimuli($sClass);
if (count($aClassStimuli) > 0) {
$aStimuli = [];
foreach ($aClassStimuli as $sStimulusCode => $oStimulus) {
if (UserRights::IsStimulusAllowed($sClass, $sStimulusCode, null, $oUser)) {
$aStimuli[] =
$oStimulus->GetLabel();
}
}
$sStimuli = implode(', ', $aStimuli);
} else {
$sStimuli = '';
}
if (
UserRights::IsActionAllowed($sClass, UR_ACTION_MODIFY, null, $oUser) ||
UserRights::IsActionAllowed($sClass, UR_ACTION_BULK_MODIFY, null, $oUser) ||
UserRights::IsActionAllowed($sClass, UR_ACTION_DELETE, null, $oUser) ||
UserRights::IsActionAllowed($sClass, UR_ACTION_BULK_DELETE, null, $oUser) ||
$sStimuli != ''
) {
UserRights::Logoff();
return false;
}
}
UserRights::Logoff();
return true;
}
/**
* @throws DictExceptionMissingString
* @throws CoreException
* @throws Exception
*/
public function GetReadOnlyUsers(): array
{
$aReadOnlyUsers = [];
$oAllUsersFilter = $this->GetAllUsers();
$aAllUsers = $this->GetUsersFromFilter($oAllUsersFilter);
/** @var User $oUser */
foreach ($aAllUsers as $oUser) {
$bIsReadOnlyUser = true;
if (!$this->IsUserReadOnly($oUser, 'bizmodel') ||
!$this->IsUserReadOnly($oUser, 'grant_by_profile')) {
$bIsReadOnlyUser = false;
}
if ($bIsReadOnlyUser) {
$aReadOnlyUsers[] = $oUser;
}
}
// TODO remove disabled users
return $aReadOnlyUsers;
}
/**
* @throws Exception
*/
public function getPortalUsers(bool $bAllData = true): null|DBObjectSearch|DBUnionSearch
{
$sOQLPortalUser = '
SELECT User AS u
JOIN URP_UserProfile AS uup ON uup.userid = u.id
JOIN URP_Profiles AS up ON uup.profileid = up.id
WHERE up.name = \' '.PORTAL_PROFILE_NAME.'\'';
try {
$oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLPortalUser) : DBObjectSearch::FromOQL($sOQLPortalUser);
}
catch (Exception $e) {
IssueLog::Error('combodo-users-quota-slave/GetUsersInQuota : '.$e->getMessage(), 'combodo-users-quota');
throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:PortalUsers')));
}
// TODO remove read only users
return $oFilter;
}
/**
* @throws CoreUnexpectedValue
* @throws CoreException
* @throws MySQLException
*/
public function GetUsersFromFilter(DBObjectSearch|DBUnionSearch|null $oFilter, array $aOrderBy = [], array $aArgs = []): array
{
$aUsers = [];
if (is_null($oFilter)) {
return $aUsers;
}
$oSet = new DBObjectSet($oFilter, $aOrderBy, $aArgs);
while ($oUser = $oSet->fetch()) {
$aUsers[] = $oUser;
}
return $aUsers;
}
/**
* @throws Exception
*/
public function GetAllUsers(bool $bAllData = true): DBUnionSearch|DBObjectSearch|DBSearch|null
{
$sOqlUser = 'SELECT User';
try {
$oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOqlUser) : DBObjectSearch::FromOQL($sOqlUser);
}
catch (Exception $e) {
IssueLog::Error('combodo-users-quota-slave/GetUsersNotInQuota : '.$e->getMessage(), 'combodo-users-quota');
throw new Exception(Dict::S('CombodoUserQuota:Error'));
}
return $oFilter;
}
}

View File

@@ -34,6 +34,7 @@ use DBObject;
use DBObjectSearch;
use DBObjectSet;
use DeleteException;
use Dict;
use MetaModel;
use UserLocal;
use UserRights;
@@ -96,6 +97,127 @@ class UserRightsTest extends ItopDataTestCase
return $oUser;
}
/**
* @param array $aProfileIds
* @param array $aShouldBeAllowedToSeeClass
* @param array $aShouldBeAllowedToEditClass
*
* @return void
* @throws \ArchivedObjectException
* @throws \CoreCannotSaveObjectException
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \CoreWarning
* @throws \DictExceptionUnknownLanguage
* @throws \MySQLException
* @throws \OQLException
* @dataProvider ReadOnlyProvider
*/
public function testReadOnlyUser(array $aProfileIds, array $aShouldBeAllowedToSeeClass, array $aShouldBeAllowedToEditClass): void
{
$oUser = $this->GivenUserWithProfiles('test1', $aProfileIds);
$oUser->DBInsert();
$_SESSION = [];
UserRights::Login($oUser->Get('login'));
$aClassesToTest = ['FunctionalCI', 'Ticket', 'ServiceFamily'];
foreach ($aClassesToTest as $sClass) {
$bShouldBeAllowedToSee = in_array($sClass, $aShouldBeAllowedToSeeClass);
$bIsAllowedReading = (bool)UserRights::IsActionAllowed($sClass, UR_ACTION_READ);
$this->assertSame(
$bShouldBeAllowedToSee,
$bIsAllowedReading,
"User with profiles ".implode(',', $aProfileIds)." should ".($bShouldBeAllowedToSee ? "" : "NOT ")."be allowed to see class $sClass"
);
$bShouldBeAllowedToEdit = in_array($sClass, $aShouldBeAllowedToEditClass);
$bIsAllowedEditing = (bool)UserRights::IsActionAllowed($sClass, UR_ACTION_MODIFY);
$this->assertSame($bIsAllowedEditing, $bShouldBeAllowedToEdit,
"User with profiles ".implode(',', $aProfileIds)." should ".($bShouldBeAllowedToEdit ? "" : "NOT ")."be allowed to edit class $sClass"
);
}
}
protected function ReadOnlyProvider() : array {
return [
'CI' => [
'ProfilesId' => [
5500,
],
'ShouldBeAllowedToSeeClasses' => [
'FunctionalCI',
],
'ShouldBeAllowedToEditClasses' => []
],
'Tickets' => [
'ProfilesId' => [
5501,
],
'ShouldBeAllowedToSeeClasses' => [
'Ticket',
],
'ShouldBeAllowedToEditClasses' => []
],
'Catalog' => [
'ProfilesId' => [
5502,
],
'ShouldBeAllowedToSeeClasses' => [
'ServiceFamily',
],
'ShouldBeAllowedToEditClasses' => []
],
'CI and Tickets' => [
'ProfilesId' => [
5500, 5501,
],
'ShouldBeAllowedToSeeClasses' => [
'FunctionalCI', 'Ticket',
],
'ShouldBeAllowedToEditClasses' => []
],
'CI and Catalog' => [
'ProfilesId' => [
5500, 5502,
],
'ShouldBeAllowedToSeeClasses' => [
'FunctionalCI', 'ServiceFamily',
],
'ShouldBeAllowedToEditClasses' => []
],
'Tickets and Catalog' => [
'ProfilesId' => [
5501, 5502,
],
'ShouldBeAllowedToSeeClasses' => [
'Ticket', 'ServiceFamily',
],
'ShouldBeAllowedToEditClasses' => []
],
'Tickets and Catalog + profile Ccnfiguration Manager' => [
'ProfilesId' => [
5501, 5502, 3
],
'ShouldBeAllowedToSeeClasses' => [
'FunctionalCI', 'Ticket', 'ServiceFamily',
],
'ShouldBeAllowedToEditClasses' => ['FunctionalCI']
],
'CI, Tickets and Catalog' => [
'ProfilesId' => [
5500, 5501, 5502,
],
'ShouldBeAllowedToSeeClasses' => [
'FunctionalCI', 'Ticket', 'ServiceFamily',
],
'ShouldBeAllowedToEditClasses' => []
],
];
}
public function testIsLoggedIn()
{
$this->assertFalse(UserRights::IsLoggedIn());
@@ -433,7 +555,7 @@ class UserRightsTest extends ItopDataTestCase
$oUser = $this->GivenUserWithProfiles('test1', [$iProfileId, 2]);
$this->expectException(CoreCannotSaveObjectException::class);
$this->expectExceptionMessage('Profile "Portal user" cannot be given to privileged Users (Administrators, SuperUsers and REST Services Users)');
$this->expectExceptionMessage(Dict::Format('Class:User/Error:PrivilegedUserMustHaveAccessToBackOffice', PORTAL_PROFILE_NAME));
$oUser->DBInsert();
}

View File

@@ -0,0 +1,187 @@
<?php
namespace Users;
use CMDBObjectSet;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use Combodo\iTop\Users\ITopUserQuotaRepository;
use DBObjectSearch;
use MetaModel;
use User;
class ITopUserQuotaRepositoryTest extends ItopDataTestCase{
private static bool $bDatasetInitialized = false;
protected function setUp(): void
{
parent::setUp();
if (self::$bDatasetInitialized) {
return;
}
$this->createUsersQuotaDataset();
self::$bDatasetInitialized = true;
}
/** * Creates a deterministic dataset for quota tests. * Users are created only once (idempotent on login). */
private function createUsersQuotaDataset(): void
{
// Keep names unique and easy to clean up later if needed.
$sPrefix = 'quota_test_';
// Create one user per quota "kind".
// NOTE: profile names can vary by iTop distribution; we try common ones.
$this->createUserIfMissing($sPrefix.'console', true, ['Administrator', 'Configuration Administrator']);
$this->createUserIfMissing($sPrefix.'portal', true, ['Portal user', 'Portal User']);
$this->createUserIfMissing($sPrefix.'readonly', true, ['ReadOnlyCI']);
$this->createUserIfMissing($sPrefix.'application', true, ['Service Desk Agent', 'Change Manager', 'Administrator']);
$this->createUserIfMissing($sPrefix.'disabled', false, ['Service Desk Agent', 'Administrator']);
$this->createUserIfMissing($sPrefix.'disabled', false, ['Service Desk Agent', 'Administrator']);
}
private function createUserIfMissing(string $sLogin, bool $bEnabled, array $aCandidateProfileNames): void
{
if ($this->findUserByLogin($sLogin) !== null) {
return;
}
$iProfileId = $this->findFirstProfileIdByNames($aCandidateProfileNames);
$this->assertNotNull(
$iProfileId,
sprintf('Could not find any profile among: %s', implode(', ', $aCandidateProfileNames))
);
$oOrg = MetaModel::NewObject('Organization');
$oOrg->Set('name', 'Quota Test Org');
$oOrg->DBInsert();
$oPerson = MetaModel::NewObject('Person');
$oPerson->Set('name', strtoupper($sLogin));
$oPerson->Set('first_name', 'Quota');
$oPerson->Set('org_id', $oOrg->GetKey());
$oPerson->Set('email', $sLogin.'@example.invalid');
$oPerson->DBInsert();
$oUser = MetaModel::NewObject('UserLocal');
$oUser->Set('login', $sLogin);
$oUser->Set('password', 'QuotaTest#123');
$oUser->Set('contactid', $oPerson->GetKey());
$oUser->Set('status', $bEnabled ? 'enabled' : 'disabled');
$oProfileList = $oUser->Get('profile_list');
$oLink = MetaModel::NewObject('URP_UserProfile');
$oLink->Set('profileid', $iProfileId);
$oProfileList->AddItem($oLink);
$oUser->Set('profile_list', $oProfileList);
$oUser->DBInsert();
}
private function findFirstProfileIdByNames(array $aProfileNames): ?int
{
foreach ($aProfileNames as $sProfileName) {
$oSearch = DBObjectSearch::FromOQL('SELECT URP_Profiles WHERE name = :name');
$oSet = new CMDBObjectSet($oSearch, [], ['name' => $sProfileName]);
$oProfile = $oSet->Fetch();
if ($oProfile !== false && $oProfile !== null) {
return (int) $oProfile->GetKey();
}
}
return null;
}
private function findUserByLogin(string $sLogin): ?User
{
$oSearch = DBObjectSearch::FromOQL('SELECT User WHERE login = :login');
$oSet = new CMDBObjectSet($oSearch, [], ['login' => $sLogin]);
$oUser = $oSet->Fetch();
return ($oUser instanceof User) ? $oUser : null;
}
public function testNotDuplicateInDifferentQuotas(): void
{
$oITopUserRepository = new ITopUserQuotaRepository();
$aQuotaUsers = [
'console' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetConsoleUsers()),
'portal' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetPortalUsers()),
'disabled' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetDisabledUsers()),
'readonly' => $oITopUserRepository->GetReadOnlyUsers(),
'application' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetApplicationUsers()),
];
$aUserToQuotas = [];
foreach ($aQuotaUsers as $sQuota => $aUsers) {
foreach ($aUsers as $oUser) {
$sUserId = (string) $oUser->GetKey();
$aUserToQuotas[$sUserId][$sQuota] = true;
}
}
$aDuplicates = [];
foreach ($aUserToQuotas as $sUserId => $aQuotas) {
$aQuotaNames = array_keys($aQuotas);
if (count($aQuotaNames) > 1) {
sort($aQuotaNames);
$aDuplicates[] = sprintf('User #%s appears in: %s', $sUserId, implode(', ', $aQuotaNames));
}
}
$this->assertEmpty(
$aDuplicates,
"Some users are counted in multiple quotas:\n- ".implode("\n- ", $aDuplicates)
);
}
public function testAllUsersAreInQuota () {
$oITopUserRepository = new ITopUserQuotaRepository();
$oConsoleUsersFilter = $oITopUserRepository->GetConsoleUsers();
$aConsoleUsers = $oITopUserRepository->GetUsersFromFilter($oConsoleUsersFilter);
$oPortalUsersFilter = $oITopUserRepository->GetPortalUsers();
$aPortalUsers = $oITopUserRepository->GetUsersFromFilter($oPortalUsersFilter);
$oDisabledUsersFilter = $oITopUserRepository->GetDisabledUsers();
$aDisabledUsers = $oITopUserRepository->GetUsersFromFilter($oDisabledUsersFilter);
$aReadOnlyUsers = $oITopUserRepository->GetReadOnlyUsers();
$oApplicationUsersFilter = $oITopUserRepository->GetApplicationUsers();
$aApplicationUsers = $oITopUserRepository->GetUsersFromFilter($oApplicationUsersFilter);
$aAllUsersFromQuota = array_merge($aConsoleUsers, $aPortalUsers, $aDisabledUsers, $aReadOnlyUsers, $aApplicationUsers);
$oAllUsersFilter = $oITopUserRepository->GetAllUsers();
$aAllUsersFromOQL = $oITopUserRepository->GetUsersFromFilter($oAllUsersFilter);
$this->assertEmpty(array_merge(array_diff($aAllUsersFromQuota, $aAllUsersFromOQL), array_diff($aAllUsersFromOQL, $aAllUsersFromQuota)));
}
public function testAllUsersInQuotaAreUsersObjects ()
{
$oITopUserRepository = new ITopUserQuotaRepository();
$oConsoleUsersFilter = $oITopUserRepository->GetConsoleUsers();
$aConsoleUsers = $oITopUserRepository->GetUsersFromFilter($oConsoleUsersFilter);
$oPortalUsersFilter = $oITopUserRepository->GetPortalUsers();
$aPortalUsers = $oITopUserRepository->GetUsersFromFilter($oPortalUsersFilter);
$oDisabledUsersFilter = $oITopUserRepository->GetDisabledUsers();
$aDisabledUsers = $oITopUserRepository->GetUsersFromFilter($oDisabledUsersFilter);
$aReadOnlyUsers = $oITopUserRepository->GetReadOnlyUsers();
$oApplicationUsersFilter = $oITopUserRepository->GetApplicationUsers();
$aApplicationUsers = $oITopUserRepository->GetUsersFromFilter($oApplicationUsersFilter);
$aAllQuotaUsers = array_merge($aConsoleUsers, $aPortalUsers, $aDisabledUsers, $aReadOnlyUsers, $aApplicationUsers);
foreach ($aAllQuotaUsers as $oUser) {
$this->assertInstanceOf(User::class, $oUser);
}
}
}

View File

@@ -1,6 +1,12 @@
<?php
use Combodo\iTop\Core\Email\EMailSymfony;
use Combodo\iTop\Test\UnitTest\ItopTestCase;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\AlternativePart;
use Symfony\Component\Mime\Part\Multipart\RelatedPart;
use Symfony\Component\Mime\Part\TextPart;
class EmailSymfonyTest extends ItopTestCase
{
@@ -135,4 +141,221 @@ HTML;
$this->assertSame($sExpectedBody, $sActualBody);
}
/**
* Returns the parts of the AlternativePart produced by SetBody() for an HTML email.
*
* Handles both the simple case (AlternativePart at root) and the inline-images case
* where the root is a RelatedPart whose first child is the AlternativePart.
*
* @return AbstractPart[]
*/
private function GetAlternativePartsFromHtmlEmail(EMailSymfony $oEmail): array
{
$oSymfonyMessage = $this->GetNonPublicProperty($oEmail, 'm_oMessage');
$oBody = $oSymfonyMessage->getBody();
// With inline images the root is a RelatedPart; the AlternativePart is its first child.
if ($oBody instanceof RelatedPart) {
$oBody = $oBody->getParts()[0];
}
$this->assertInstanceOf(AlternativePart::class, $oBody, 'Body should be a multipart/alternative for HTML emails');
return $oBody->getParts();
}
/**
* RFC 2046 §5.1.4: parts in multipart/alternative must be ordered from least to most preferred.
* Email clients display the last part they support, so text/plain must come first and text/html last.
*
* @see https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
* @since N°9574
*/
public function testSetBodyAlternativePartOrderForHtmlEmailIsPlainThenHtml(): void
{
$oEmail = new EMailSymfony();
$oEmail->SetBody('<p>Hello there!</p>', 'text/html');
[$oFirstPart, $oSecondPart] = $this->GetAlternativePartsFromHtmlEmail($oEmail);
$this->assertSame('plain', $oFirstPart->getMediaSubtype(), 'First part must be text/plain (least preferred per RFC 2046)');
$this->assertSame('html', $oSecondPart->getMediaSubtype(), 'Last part must be text/html (most preferred per RFC 2046)');
}
/**
* @dataProvider provideSetBodyPlainTextDoesNotContainCss
*
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
* @since N°9574
*/
public function testSetBodyPlainTextDoesNotContainCss(string $sHtml, ?string $sCustomStyles): void
{
$oEmail = new EMailSymfony();
$oEmail->SetBody($sHtml, 'text/html', $sCustomStyles);
// We locate the plain text part by subtype to be order-agnostic and isolate this assertion from the order bug.
$aParts = $this->GetAlternativePartsFromHtmlEmail($oEmail);
$oPlainPart = null;
foreach ($aParts as $oPart) {
if ($oPart instanceof TextPart && $oPart->getMediaSubtype() === 'plain') {
$oPlainPart = $oPart;
break;
}
}
$this->assertNotNull($oPlainPart, 'No text/plain part found in the message');
$sPlainText = $oPlainPart->getBody();
$this->assertStringNotContainsString('<style>', $sPlainText, 'Style tag must not appear in plain text');
$this->assertStringNotContainsString('color:', $sPlainText, 'CSS color rule must not appear in plain text');
$this->assertStringNotContainsString('font-size:', $sPlainText, 'CSS font-size rule must not appear in plain text');
$this->assertStringNotContainsString('@media', $sPlainText, 'CSS @media rule must not appear in plain text');
$this->assertStringContainsString('Hello there!', $sPlainText, 'Actual content must be preserved in plain text');
}
/**
* The HTML part must contain the body content and the CSS inlined by Emogrifier.
* This guards against regressions where the wrong body (e.g. the plain-text version)
* would end up in the HTML part.
*
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
* @since N°9574
*/
public function testSetBodyHtmlPartContainsBodyAndInlinedCss(): void
{
$oEmail = new EMailSymfony();
$oEmail->SetBody('<html><body><p>Hello there!</p></body></html>', 'text/html', 'p { color: red; }');
$aParts = $this->GetAlternativePartsFromHtmlEmail($oEmail);
$oHtmlPart = null;
foreach ($aParts as $oPart) {
if ($oPart instanceof TextPart && $oPart->getMediaSubtype() === 'html') {
$oHtmlPart = $oPart;
break;
}
}
$this->assertNotNull($oHtmlPart, 'No text/html part found in the message');
$sHtmlContent = $oHtmlPart->getBody();
$this->assertStringContainsString('Hello there!', $sHtmlContent, 'HTML part must preserve the original text content');
$this->assertStringContainsString('color: red', $sHtmlContent, 'HTML part must contain the CSS inlined by Emogrifier');
}
/**
* With inline images, SetBody() wraps the AlternativePart in a RelatedPart.
* The AlternativePart must still be correctly ordered (plain first, HTML last)
* and the plain-text part must not contain CSS.
*
* @see https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
* @since N°9574
*/
public function testSetBodyWithInlineImagesHasCorrectPartStructure(): void
{
// Anonymous subclass so we can inject a fake inline image part without a real inline image in DB
$oEmail = new class () extends EMailSymfony {
protected function EmbedInlineImages(string &$sBody): array
{
return [new DataPart('fake-image-data', 'image.png', 'image/png')];
}
};
$oEmail->SetBody('<html><head><style>p { color: red; }</style></head><body><p>Hello there!</p></body></html>', 'text/html');
$oSymfonyMessage = $this->GetNonPublicProperty($oEmail, 'm_oMessage');
$oBody = $oSymfonyMessage->getBody();
// Root must be a RelatedPart when inline images are present
$this->assertInstanceOf(RelatedPart::class, $oBody, 'Root part must be multipart/related when inline images are present');
// The AlternativePart must be the first child of the RelatedPart
$aRelatedParts = $oBody->getParts();
$this->assertInstanceOf(AlternativePart::class, $aRelatedParts[0], 'First child of RelatedPart must be the AlternativePart');
// Order and CSS checks are delegated to the shared helper, which now handles RelatedPart
[$oFirstPart, $oSecondPart] = $this->GetAlternativePartsFromHtmlEmail($oEmail);
$this->assertSame('plain', $oFirstPart->getMediaSubtype(), 'First part must be text/plain (least preferred per RFC 2046)');
$this->assertSame('html', $oSecondPart->getMediaSubtype(), 'Last part must be text/html (most preferred per RFC 2046)');
}
public function provideSetBodyPlainTextDoesNotContainCss(): array
{
$sCustomStyles = 'p { color: blue; font-size: 14px; }';
return [
'<style> tag in HTML, no custom styles' => [
'<html><head><style>body { color: red; font-size: 12px; } @media print { p { color: black; } }</style></head><body><p>Hello there!</p></body></html>',
null,
],
'<style> tag in HTML with custom styles' => [
'<html><head><style>body { color: red; font-size: 12px; } @media print { p { color: black; } }</style></head><body><p>Hello there!</p></body></html>',
$sCustomStyles,
],
'custom styles only, no <style> tag' => [
'<html><body><p>Hello there!</p></body></html>',
$sCustomStyles,
],
];
}
/**
* @dataProvider provideCreateSmtpTransportSslOptions
*/
public function testCreateSmtpTransportSslOptions(string $sDsn, bool $bVerifyPeer, array $aExpectedSslOptions): void
{
$oEmail = new EMailSymfony();
/** @var EsmtpTransport $oTransport */
$oTransport = $this->InvokeNonPublicMethod(EMailSymfony::class, 'CreateSmtpTransport', $oEmail, [$sDsn, $bVerifyPeer]);
$aActualSslOptions = $oTransport->getStream()->getStreamOptions()['ssl'] ?? [];
$this->assertSame($aExpectedSslOptions, $aActualSslOptions);
}
public function provideCreateSmtpTransportSslOptions(): array
{
$aDisabledVerification = [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
];
return [
// Regression scenario (N°9584): STARTTLS starts the connection unencrypted, so the 'ssl' key
// is absent from stream options at construction time. verify_peer=false must still be applied.
'STARTTLS, verify_peer=false' => [
'smtp://localhost:587?encryption=starttls',
false,
$aDisabledVerification,
],
'implicit TLS (smtps), verify_peer=false' => [
'smtps://localhost:465',
false,
$aDisabledVerification,
],
'plain SMTP, verify_peer=false' => [
'smtp://localhost:25',
false,
$aDisabledVerification,
],
// Default behavior: verify_peer=true must leave stream options untouched (empty).
'STARTTLS, verify_peer=true (default)' => [
'smtp://localhost:587?encryption=starttls',
true,
[],
],
'implicit TLS (smtps), verify_peer=true (default)' => [
'smtps://localhost:465',
true,
[],
],
'plain SMTP, verify_peer=true (default)' => [
'smtp://localhost:25',
true,
[],
],
];
}
}