Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/984: bulk update notification email addresses #1066

Open
wants to merge 71 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
b09acd1
#984: setNotifEmailAddr command added
phjulia Aug 1, 2023
000778e
#984: added a function to update notifications for Automations
phjulia Aug 3, 2023
76dda3e
#984: move updateNotifications under runMethod
phjulia Aug 7, 2023
49440b9
#984: enable updating notes or email addresses, additional checks, mo…
phjulia Aug 7, 2023
10a3fe3
#984: added 2 test cases for updateNotifications
phjulia Aug 7, 2023
19f1318
#984: updated function name
phjulia Aug 10, 2023
24543f0
#984: updateNotifications improvements
phjulia Aug 12, 2023
581a3a1
Merge branch 'develop' into feature/984-feature-bulk-update-notificat…
phjulia Aug 12, 2023
0f1c3fe
#984: fixed tests, adjusted logic to set some notifications, check if…
phjulia Aug 13, 2023
48e37e1
#984: fixed tests - +2 api requests because there is a new test autom…
phjulia Aug 13, 2023
e7c93c2
#984: moved repetitive code into #getEncodedAutomationIDs()
phjulia Aug 13, 2023
92dfd6e
#984: added interactive questions, no email validation at this point
phjulia Aug 13, 2023
9934cbd
#984: corrected test
phjulia Aug 13, 2023
83f679d
#984: remove notifications option
phjulia Aug 13, 2023
ae4cc5d
#984: added email validator
phjulia Aug 14, 2023
309de98
#984: added a test for importFile
phjulia Aug 14, 2023
94d8c11
#984: added a test for --clear option
phjulia Aug 20, 2023
18cd195
Merge branch 'develop' into feature/984-feature-bulk-update-notificat…
phjulia Aug 20, 2023
ceefc04
#984: +1 test automations, the rest of the tests were adapted
phjulia Aug 20, 2023
127e1e4
#984: --clear option for importFile
phjulia Aug 20, 2023
d87161a
#984: exit updateNotifications for importFile if user provided option…
phjulia Aug 20, 2023
00704bf
#984: note was not set if email address was not provided
phjulia Aug 20, 2023
3910110
#984: retrive for clearNotifications is already covered
phjulia Aug 20, 2023
e8f7744
#984: some improvements and more tests
phjulia Aug 21, 2023
556d256
#984: ternary -> if
phjulia Aug 21, 2023
1d30d9e
#984: removed error variable
phjulia Aug 21, 2023
051b215
Update test/type.importFile.test.js
phjulia Aug 21, 2023
9f822f6
Update test/type.automation.test.js
phjulia Aug 21, 2023
720231c
#984: test update completion email address and error email address
phjulia Aug 21, 2023
739eaf8
#984: +9 positive test cases
phjulia Aug 23, 2023
10bf762
Merge branch 'feature/984-feature-bulk-update-notification-email-addr…
phjulia Aug 23, 2023
a38f02b
#984: +1 negative test case
phjulia Aug 24, 2023
6d87df6
#984: group:
phjulia Aug 24, 2023
eff4b1a
#984: moved properties out of static variables
phjulia Aug 24, 2023
55a3389
#984: removed static variables
phjulia Aug 24, 2023
d07cf91
Update lib/cli.js
phjulia Aug 24, 2023
47c847d
#984: made getKeysToSetNotifications in MetadataType abstract, moved …
phjulia Aug 26, 2023
e07fba6
Merge branch 'feature/984-feature-bulk-update-notification-email-addr…
phjulia Aug 26, 2023
5bdfa31
#984: clearNotifications was removed from MetadataType and index
phjulia Aug 26, 2023
4cc50b3
#984: info to verbose
phjulia Aug 26, 2023
592d3fb
#984: renamed negative test cases
phjulia Aug 26, 2023
989fed4
#984: renamed negative test cases
phjulia Aug 26, 2023
695afb0
#984: renamed test cases
phjulia Aug 26, 2023
fc03b1d
#984: split a test case into 2
phjulia Aug 26, 2023
729eb96
#984: split a test case into 3
phjulia Aug 26, 2023
f691e6c
#984: +3 tests for clearNotifications (+3 cached automations, +6 API …
phjulia Aug 27, 2023
3023492
#984: improved regex email validation
phjulia Aug 27, 2023
9abe1e5
#984: moved type specific stuff into child class
phjulia Aug 27, 2023
1609171
#984: removed options from updateNotifications command
phjulia Aug 27, 2023
9bb5be7
#984: added a test for updating a note only
phjulia Aug 28, 2023
0dc51ae
#984: +1 automation
phjulia Aug 28, 2023
23d0cea
#984: added a function to retrieve and deploy that is used for fixKey…
phjulia Aug 30, 2023
12f7b3d
#984: regex for email validation replaced
phjulia Aug 30, 2023
fe22dca
#984: add questions to an array
phjulia Aug 30, 2023
0b43078
#984: validate added
phjulia Aug 30, 2023
6c09a25
Merge branch 'develop' into feature/984-feature-bulk-update-notificat…
phjulia Aug 30, 2023
9ddbefe
#984: test cases
phjulia Aug 31, 2023
f1ff0de
Merge branch 'develop' into feature/984-feature-bulk-update-notificat…
phjulia Aug 31, 2023
c18e6ef
#984: +15 automations/+15 API calls
phjulia Aug 31, 2023
7b0de99
#984: fixed issue with email validation
phjulia Sep 3, 2023
1101102
#984: merge develop into branch
phjulia Sep 24, 2023
7fb1fb4
Merge branch 'develop' into feature/984-feature-bulk-update-notificat…
JoernBerkefeld Oct 9, 2023
af22fb9
Merge branch 'develop' into feature/984-feature-bulk-update-notificat…
JoernBerkefeld Oct 9, 2023
bd755f4
#0: auto-formatting
JoernBerkefeld Oct 9, 2023
258c067
#984: improve log message for updateNotification
JoernBerkefeld Oct 9, 2023
63f9784
#984: move updateNotification logic from custom createOrUpdate into p…
JoernBerkefeld Oct 9, 2023
8e2670a
#984: rename importFile test for updateNotification
JoernBerkefeld Oct 10, 2023
9981086
#984: refactoring
JoernBerkefeld Oct 11, 2023
801317d
Merge branch 'develop' into feature/984-feature-bulk-update-notificat…
JoernBerkefeld Feb 27, 2024
f1df201
#0: pre-commit hook logs
JoernBerkefeld Feb 27, 2024
6626df6
#984: fix tests for importFile & query
JoernBerkefeld Feb 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/dist/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ Provides default functionality that can be overwritten by child metadata type cl
<dt><a href="#Mcdev.">Mcdev.(selectedType, buObject)</a> ⇒ <code>Array.&lt;string&gt;</code></dt>
<dd><p>helper for <a href="Mcdev.#runOnBU">Mcdev.#runOnBU</a></p>
</dd>
<dt><a href="#Mcdev.">Mcdev.(cred, bu, type, [keys])</a> ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code></dt>
<dd></dd>
<dt><a href="#Automation.">Automation.(metadata)</a> ⇒ <code>boolean</code></dt>
<dd><p>helper for <a href="#Automation.postRetrieveTasks">postRetrieveTasks</a> and <a href="#Automation.execute">execute</a></p>
</dd>
Expand All @@ -216,6 +218,9 @@ Provides default functionality that can be overwritten by child metadata type cl
<dt><a href="#Automation.">Automation.(metadataMap, originalMetadataMap, key)</a> ⇒ <code>Promise.&lt;{key:string, response:object}&gt;</code></dt>
<dd><p>helper for <a href="#Automation.postDeployTasks">postDeployTasks</a></p>
</dd>
<dt><a href="#Automation.">Automation.()</a> ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code></dt>
<dd><p>helper function to retrieve data about all automations in the BU</p>
</dd>
<dt><a href="#getUserName">getUserName(userList, item, fieldname)</a> ⇒ <code>string</code></dt>
<dd></dd>
<dt><a href="#setupSDK">setupSDK(sessionKey, authObject)</a> ⇒ <code><a href="#SDK">SDK</a></code></dt>
Expand Down Expand Up @@ -520,6 +525,7 @@ main class
* [.execute(businessUnit, [selectedType], [keys])](#Mcdev.execute) ⇒ <code>Promise.&lt;Object.&lt;string, Array.&lt;string&gt;&gt;&gt;</code>
* [.pause(businessUnit, [selectedType], [keys])](#Mcdev.pause) ⇒ <code>Promise.&lt;Object.&lt;string, Array.&lt;string&gt;&gt;&gt;</code>
* [.fixKeys(businessUnit, type, [keys])](#Mcdev.fixKeys) ⇒ <code>Promise.&lt;Object.&lt;string, Array.&lt;string&gt;&gt;&gt;</code>
* [.updateNotifications(businessUnit, type, [keys])](#Mcdev.updateNotifications) ⇒ <code>Promise.&lt;Object.&lt;string, Array.&lt;string&gt;&gt;&gt;</code>

<a name="Mcdev.setSkipInteraction"></a>

Expand Down Expand Up @@ -827,6 +833,20 @@ Updates the key to match the name field
| type | <code>TYPE.SupportedMetadataTypes</code> | limit execution to given metadata type |
| [keys] | <code>Array.&lt;string&gt;</code> | customerkey of the metadata |

<a name="Mcdev.updateNotifications"></a>

### Mcdev.updateNotifications(businessUnit, type, [keys]) ⇒ <code>Promise.&lt;Object.&lt;string, Array.&lt;string&gt;&gt;&gt;</code>
Updates notification email address field

**Kind**: static method of [<code>Mcdev</code>](#Mcdev)
**Returns**: <code>Promise.&lt;Object.&lt;string, Array.&lt;string&gt;&gt;&gt;</code> - key: business unit name, value: list of affected item keys

| Param | Type | Description |
| --- | --- | --- |
| businessUnit | <code>string</code> | name of BU |
| type | <code>TYPE.SupportedMetadataTypes</code> | limit execution to given metadata type |
| [keys] | <code>Array.&lt;string&gt;</code> | customerkey of the metadata |

<a name="Asset"></a>

## Asset ⇐ [<code>MetadataType</code>](#MetadataType)
Expand Down Expand Up @@ -1384,6 +1404,7 @@ Automation MetadataType
* [.getFilesToCommit(keyArr)](#Automation.getFilesToCommit) ⇒ <code>Array.&lt;string&gt;</code>
* [.deleteByKey(customerKey)](#Automation.deleteByKey) ⇒ <code>boolean</code>
* [.postDeleteTasks(customerKey)](#Automation.postDeleteTasks) ⇒ <code>void</code>
* [.updateNotifications(keys)](#Automation.updateNotifications) ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code>

<a name="Automation.retrieve"></a>

Expand Down Expand Up @@ -1650,6 +1671,18 @@ clean up after deleting a metadata item
| --- | --- | --- |
| customerKey | <code>string</code> | Identifier of metadata item |

<a name="Automation.updateNotifications"></a>

### Automation.updateNotifications(keys) ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code>
A function to update automation email notifications

**Kind**: static method of [<code>Automation</code>](#Automation)
**Returns**: <code>Promise.&lt;Array.&lt;string&gt;&gt;</code> - keys of the automations where notifications were updated

| Param | Type | Description |
| --- | --- | --- |
| keys | <code>string</code> | metadata keys |

<a name="Campaign"></a>

## Campaign ⇐ [<code>MetadataType</code>](#MetadataType)
Expand Down Expand Up @@ -3380,6 +3413,7 @@ Provides default functionality that can be overwritten by child metadata type cl
* [.readBUMetadataForType(readDir, [listBadKeys], [buMetadata])](#MetadataType.readBUMetadataForType) ⇒ <code>object</code>
* [.getFilesToCommit(keyArr)](#MetadataType.getFilesToCommit) ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code>
* [.getKeysForFixing(metadataMap)](#MetadataType.getKeysForFixing) ⇒ <code>Array.&lt;string&gt;</code>
* [.setNotifEmailAddr()](#MetadataType.setNotifEmailAddr) ⇒ <code>Array.&lt;string&gt;</code>

<a name="MetadataType.client"></a>

Expand Down Expand Up @@ -4207,6 +4241,13 @@ additionally, the documentation for dataExtension and automation should be retur
| --- | --- | --- |
| metadataMap | <code>TYPE.MetadataTypeMap</code> | metadata mapped by their keyField |

<a name="MetadataType.setNotifEmailAddr"></a>

### MetadataType.setNotifEmailAddr() ⇒ <code>Array.&lt;string&gt;</code>
Abstract setNotifEmailAddr method that needs to be implemented in child metadata type

**Kind**: static method of [<code>MetadataType</code>](#MetadataType)
**Returns**: <code>Array.&lt;string&gt;</code> - returns keys of items where notification email address was set
<a name="MobileCode"></a>

## MobileCode ⇐ [<code>MetadataType</code>](#MetadataType)
Expand Down Expand Up @@ -8469,6 +8510,19 @@ helper for [Mcdev.#runOnBU](Mcdev.#runOnBU)
| selectedType | <code>TYPE.SupportedMetadataTypes</code> | limit execution to given metadata type |
| buObject | <code>TYPE.BuObject</code> | properties for auth |

<a name="Mcdev."></a>

## Mcdev.(cred, bu, type, [keys]) ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code>
**Kind**: global function
**Returns**: <code>Promise.&lt;Array.&lt;string&gt;&gt;</code> - keys of the automations where notifications were updated

| Param | Type | Description |
| --- | --- | --- |
| cred | <code>string</code> | name of Credential |
| bu | <code>string</code> | name of BU |
| type | <code>string</code> | metadata type |
| [keys] | <code>Array.&lt;string&gt;</code> | limit retrieval to given metadata keys |

<a name="Automation."></a>

## Automation.(metadata) ⇒ <code>boolean</code>
Expand Down Expand Up @@ -8556,6 +8610,13 @@ helper for [postDeployTasks](#Automation.postDeployTasks)
| originalMetadataMap | <code>TYPE.AutomationMap</code> | metadata to be updated (contains additioanl fields) |
| key | <code>string</code> | current customer key |

<a name="Automation."></a>

## Automation.() ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code>
helper function to retrieve data about all automations in the BU

**Kind**: global function
**Returns**: <code>Promise.&lt;Array.&lt;string&gt;&gt;</code> - returns data about automations with the legacy key
<a name="getUserName"></a>

## getUserName(userList, item, fieldname) ⇒ <code>string</code>
Expand Down
47 changes: 47 additions & 0 deletions lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,53 @@ yargs
Mcdev.fixKeys(argv.BU, argv.TYPE, csvToArray(argv.KEY));
},
})
.command({
command: 'updateNotifications <BU> <TYPE> [KEY]',
aliases: ['updNotif'],
phjulia marked this conversation as resolved.
Show resolved Hide resolved
desc: 'bulk updates notification email addresses',
builder: (yargs) => {
yargs
.positional('BU', {
type: 'string',
describe: 'the business unit where to update notification email address',
})
.positional('TYPE', {
type: 'string',
describe: 'metadata type',
})
.positional('KEY', {
type: 'string',
describe: 'key(s) of the metadata component(s)',
})
.positional('emailError', {
phjulia marked this conversation as resolved.
Show resolved Hide resolved
type: 'string',
phjulia marked this conversation as resolved.
Show resolved Hide resolved
describe: 'email to notify that an error occured during execution',
})
.positional('emailComplete', {
phjulia marked this conversation as resolved.
Show resolved Hide resolved
type: 'string',
phjulia marked this conversation as resolved.
Show resolved Hide resolved
describe: 'email to notify about successfull completion',
})
.positional('errorNote', {
phjulia marked this conversation as resolved.
Show resolved Hide resolved
type: 'string',
phjulia marked this conversation as resolved.
Show resolved Hide resolved
describe: 'run error note',
})
.positional('completionNote', {
phjulia marked this conversation as resolved.
Show resolved Hide resolved
type: 'string',
phjulia marked this conversation as resolved.
Show resolved Hide resolved
describe: 'run completion note',
})
.option('like', {
type: 'string',
group: 'Options for update notification email address:',
phjulia marked this conversation as resolved.
Show resolved Hide resolved
describe:
'filter metadata components (can include % as wildcard or _ for a single character)',
});
},
handler: (argv) => {
Mcdev.setOptions(argv);
// ! do not allow multiple types to be passed in here via csvToArray
Mcdev.updateNotifications(argv.BU, argv.TYPE, csvToArray(argv.KEY));
},
})
.command({
command: 'upgrade',
aliases: ['up'],
Expand Down
83 changes: 83 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ class Mcdev {
'refresh',
'schedule',
'skipInteraction',
'emailError',
'emailComplete',
'errorNote',
'completionNote',
];
for (const option of knownOptions) {
if (argv[option] !== undefined) {
Expand Down Expand Up @@ -1126,6 +1130,85 @@ class Mcdev {
Util.logger.info(`:: Done\n`);
return result;
}
/**
* Updates notification email address field
*
* @param {string} businessUnit name of BU
* @param {TYPE.SupportedMetadataTypes} type limit execution to given metadata type
* @param {string[]} [keys] customerkey of the metadata
* @returns {Promise.<Object.<string, string[]>>} key: business unit name, value: list of affected item keys
*/
static async updateNotifications(businessUnit, type, keys) {
phjulia marked this conversation as resolved.
Show resolved Hide resolved
const [cred, bu] = businessUnit ? businessUnit.split('/') : [null, null];
if (Util.OPTIONS.emailComplete == null && Util.OPTIONS.emailError == null) {
Util.logger.error(`No email addresses were provided`);
return null;
}
const properties = await config.getProperties();
const resultsObj = {};
if (bu === '*') {
Util.logger.info(':: Updating notifications on all BUs for all credentials');
let counter_credTotal = 0;
for (const cred in properties.credentials) {
Util.logger.info(`:: Updating notifications on BUs for ${cred}`);
let counter_credBu = 0;
for (const bu in properties.credentials[cred].businessUnits) {
resultsObj[`${cred}/${bu}`] = await this.#updateNotificationsBU(
cred,
bu,
type,
keys
);
counter_credBu++;
Util.startLogger(true);
}
counter_credTotal += counter_credBu;
Util.logger.info(`:: ${counter_credBu} BUs of ${cred}\n`);
}
Util.logger.info(`:: Updated notifications on ${counter_credTotal} BUs of ${cred}\n`);
} else {
resultsObj[`${cred}/${bu}`] = await this.#updateNotificationsBU(cred, bu, type, keys);
}

return resultsObj;
}
/**
*
* @param {string} cred name of Credential
* @param {string} bu name of BU
* @param {string} type metadata type
* @param {string[]} [keys] limit retrieval to given metadata keys
* @returns {Promise.<string[]>} keys of the automations where notifications were updated
*/
static async #updateNotificationsBU(cred, bu, type, keys) {
phjulia marked this conversation as resolved.
Show resolved Hide resolved
const properties = await config.getProperties();
if (!(await config.checkProperties(properties))) {
return null;
}
const buObject = await Cli.getCredentialObject(
properties,
cred === null ? null : cred + '/' + bu,
null,
true
);
if (buObject !== null) {
cache.initCache(buObject);
cred = buObject.credential;
bu = buObject.businessUnit;
}
try {
MetadataTypeInfo[type].client = auth.getSDK(buObject);
} catch (ex) {
Util.logger.error(ex.message);
return null;
}
Util.logger.info(
`Setting notification email address of type ${type} on ${cred}/${bu}` +
(keys ? Util.getKeysString(keys) : '') +
`\n`
);
return await MetadataTypeInfo[type].updateNotifications(keys);
}
}

module.exports = Mcdev;
110 changes: 110 additions & 0 deletions lib/metadataTypes/Automation.js
Original file line number Diff line number Diff line change
Expand Up @@ -1558,6 +1558,116 @@ class Automation extends MetadataType {
// delete local copy: retrieve/cred/bu/.../...-doc.md
await super.postDeleteTasks(customerKey, [`${this.definition.type}-doc.md`]);
}
/**
* helper function to retrieve data about all automations in the BU
*
* @returns {Promise.<string[]>} returns data about automations with the legacy key
*/
static async #getEncodedAutomationIDs() {
const keyBackup = this.definition.keyField;
const iteratorBackup = this.definition.bodyIteratorField;
this.definition.keyField = 'key';
this.definition.bodyIteratorField = 'entry';
const automationLegacyKeys = await super.retrieveREST(
undefined,
`/legacy/v1/beta/bulk/automations/automation/definition/`
);
this.definition.keyField = keyBackup;
this.definition.bodyIteratorField = iteratorBackup;
return automationLegacyKeys;
}
/**
* A function to update automation email notifications
*
* @param {string} keys metadata keys
* @returns {Promise.<string[]>} keys of the automations where notifications were updated
*/
static async updateNotifications(keys) {
phjulia marked this conversation as resolved.
Show resolved Hide resolved
const emailComplete = Array.isArray(Util.OPTIONS.emailComplete)
? Util.OPTIONS.emailComplete.join(',')
: Util.OPTIONS.emailComplete;
const emailError = Array.isArray(Util.OPTIONS.emailError)
? Util.OPTIONS.emailError.join(',')
: Util.OPTIONS.emailError;
const updatedKeys = [];
let notificationsResult;
const automationLegacyMapObj = await this.#getEncodedAutomationIDs(); // retrieve automation legacy keys to update notifications
for (const key of Object.keys(automationLegacyMapObj.metadata)) {
if (Array.isArray(keys) && keys.includes(key)) {
try {
notificationsResult = await this.client.rest.get(
'/legacy/v1/beta/automations/notifications/' +
automationLegacyMapObj.metadata[key].id
);
} catch (ex) {
Util.logger.error(
`Error retrieving notifications for automation '${key}': ${ex.message} (${ex.code}))`
);
}
// if the parameters provided are the same as the email addresses/notes already in the notifications - skip this automation
if (
notificationsResult.workers.find(
(notification) =>
(notification.notificationType === 'Complete' &&
(emailComplete !== notification.definition ||
Util.OPTIONS.completionNote !== notification.body)) ||
notificationsResult.workers.find(
(notification) =>
notification.notificationType === 'Error' &&
(emailError !== notification.definition ||
Util.OPTIONS.errorNote !== notification.body)
)
)
) {
// create payload
const notificationBody = {
programId: automationLegacyMapObj.metadata[key].id,
workers: [
phjulia marked this conversation as resolved.
Show resolved Hide resolved
{
programId: automationLegacyMapObj.metadata[key].id,
notificationType: 'Complete',
definition: emailComplete,
body: Util.OPTIONS.completionNote,
channelType: 'Account',
},
{
programId: automationLegacyMapObj.metadata[key].id,
notificationType: 'Error',
definition: emailError,
body: Util.OPTIONS.errorNote,
channelType: 'Account',
},
],
};
try {
const result = await this.client.rest.post(
'/legacy/v1/beta/automations/notifications/' +
automationLegacyMapObj.metadata[key].id,
notificationBody
);
if (result) {
// should be empty if all OK
throw new Error(result);
}
updatedKeys.push(key);
} catch (ex) {
Util.logger.error(
`Error updating notifications for automation '${key}': ${ex.message} (${ex.code}))`
);
}
Util.logger.info(
Util.getGrayMsg(` - updated notifications for automation '${key}'`)
);
} else {
Util.logger.info(
` ☇ skipping automation '${key}' - email addresses provided and run notes are the same`
);
return;
}
}
}
return updatedKeys;
}
}

// Assign definition to static attributes
Expand Down
Loading
Loading