Skip to content

Commit d3013c5

Browse files
committed
Merge branch 'main' into nlp-background-audio
2 parents 6297850 + 13f1150 commit d3013c5

File tree

16 files changed

+463
-118
lines changed

16 files changed

+463
-118
lines changed

jsapp/js/components/activity/activityLogs.query.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {keepPreviousData, useQuery} from '@tanstack/react-query';
22
import type {KoboSelectOption} from 'js/components/common/koboSelect';
3-
import type {PaginatedResponse} from 'js/dataInterface';
3+
import type {FailResponse, PaginatedResponse} from 'js/dataInterface';
44
import moment from 'moment';
55
import {QueryKeys} from 'js/query/queryKeys';
66

@@ -70,6 +70,26 @@ const getFilterOptions = async () =>
7070
setTimeout(() => resolve(mockOptions), 1000);
7171
});
7272

73+
/**
74+
* Starts the exporting process of the activity logs.
75+
* @returns {Promise<void>} The promise that starts the export
76+
*/
77+
const startActivityLogsExport = async () =>
78+
new Promise<void>((resolve, reject) => {
79+
// Simulates backend export process.
80+
setTimeout(() => {
81+
if (Math.random() > 0.5) {
82+
resolve();
83+
} else {
84+
const failResponse: FailResponse = {
85+
status: 500,
86+
statusText: 'Mocked error',
87+
};
88+
reject(failResponse);
89+
}
90+
}, 500);
91+
});
92+
7393
/**
7494
*
7595
* This is a hook that fetches activity logs from the server.
@@ -94,3 +114,9 @@ export const useActivityLogsFilterOptionsQuery = () =>
94114
queryKey: [QueryKeys.activityLogsFilter],
95115
queryFn: () => getFilterOptions(),
96116
});
117+
118+
/**
119+
* This is a hook to start the exporting process of the activity logs.
120+
* @returns {() => void} The function to start the export
121+
*/
122+
export const useExportActivityLogs = () => startActivityLogsExport;

jsapp/js/components/activity/formActivity.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@ import '../../../scss/components/_kobo.form-view.scss';
44
import type {KoboSelectOption} from '../common/koboSelect';
55
import KoboSelect from '../common/koboSelect';
66
import type {UniversalTableColumn} from 'jsapp/js/universalTable/universalTable.component';
7-
import Button from '../common/button';
87
import PaginatedQueryUniversalTable from 'jsapp/js/universalTable/paginatedQueryUniversalTable.component';
98
import type {ActivityLogsItem} from './activityLogs.query';
109
import {
1110
useActivityLogsFilterOptionsQuery,
1211
useActivityLogsQuery,
12+
useExportActivityLogs,
1313
} from './activityLogs.query';
1414
import styles from './formActivity.module.scss';
1515
import cx from 'classnames';
1616
import {formatTime} from 'jsapp/js/utils';
1717
import Avatar from '../common/avatar';
18+
import ExportToEmailButton from '../exportToEmailButton/exportToEmailButton.component';
1819

1920
const EventDescription = ({
2021
who,
@@ -56,6 +57,8 @@ export default function FormActivity() {
5657
const [selectedFilterOption, setSelectedFilterOption] =
5758
useState<KoboSelectOption | null>(null);
5859

60+
const exportData = useExportActivityLogs();
61+
5962
const handleFilterChange = (value: string | null) => {
6063
setSelectedFilterOption(
6164
filterOptions?.find((option) => option.value === value) || null
@@ -78,11 +81,9 @@ export default function FormActivity() {
7881
placeholder={t('Filter by')}
7982
options={filterOptions || []}
8083
/>
81-
<Button
82-
size='m'
83-
type='primary'
84-
startIcon='download'
84+
<ExportToEmailButton
8585
label={t('Export all data')}
86+
exportFunction={exportData}
8687
/>
8788
</div>
8889
</div>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Button from '../common/button';
2+
import KoboPrompt from '../modals/koboPrompt';
3+
import {useState} from 'react';
4+
import {handleApiFail} from 'jsapp/js/api';
5+
import type {FailResponse} from 'jsapp/js/dataInterface';
6+
7+
const MessageModal = ({onClose}: {onClose: () => void}) => (
8+
<KoboPrompt
9+
isOpen
10+
onRequestClose={onClose}
11+
title={t('Exporting data')}
12+
buttons={[
13+
{
14+
label: 'Ok',
15+
onClick: onClose,
16+
},
17+
]}
18+
>
19+
{t(
20+
"Your export request is currently being processed. Once the export is complete, you'll receive an email with all the details."
21+
)}
22+
</KoboPrompt>
23+
);
24+
25+
/**
26+
* Button to be used in views that export data to email.
27+
* The button receives a label and an export function that should return a promise.
28+
* The function is called when the button is clicked and if no error occurs, a message is shown to the user.
29+
*/
30+
export default function ExportToEmailButton({
31+
exportFunction,
32+
label,
33+
}: {
34+
exportFunction: () => Promise<void>;
35+
label: string;
36+
}) {
37+
const [isMessageOpen, setIsMessageOpen] = useState(false);
38+
const [isPending, setIsPending] = useState(false);
39+
40+
const handleClick = () => {
41+
setIsPending(true);
42+
exportFunction()
43+
.then(() => {
44+
setIsMessageOpen(true);
45+
})
46+
.catch((error) => handleApiFail(error as FailResponse))
47+
.finally(() => {
48+
setIsPending(false);
49+
});
50+
};
51+
52+
return (
53+
<>
54+
<Button
55+
size='m'
56+
type='primary'
57+
label={label}
58+
startIcon='download'
59+
onClick={handleClick}
60+
isPending={isPending}
61+
/>
62+
{isMessageOpen && (
63+
<MessageModal onClose={() => setIsMessageOpen(false)} />
64+
)}
65+
</>
66+
);
67+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type {Meta, StoryFn} from '@storybook/react';
2+
3+
import ExportToEmailButton from './exportToEmailButton.component';
4+
5+
export default {
6+
title: 'misc/ExportToEmailButton',
7+
component: ExportToEmailButton,
8+
argTypes: {
9+
label: {
10+
control: 'text',
11+
},
12+
},
13+
} as Meta<typeof ExportToEmailButton>;
14+
15+
const Template: StoryFn<typeof ExportToEmailButton> = (args) => (
16+
<ExportToEmailButton {...args} />
17+
);
18+
19+
export const Primary = Template.bind({});
20+
Primary.args = {
21+
label: 'Export all data',
22+
exportFunction: () => new Promise((resolve) => setTimeout(resolve, 500)),
23+
};

jsapp/js/components/support/helpBubble.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ class HelpBubble extends React.Component<{}, HelpBubbleState> {
7474
$targetEl.parents('.help-bubble__popup').length === 0 &&
7575
$targetEl.parents('.help-bubble__popup-content').length === 0 &&
7676
$targetEl.parents('.help-bubble__row').length === 0 &&
77-
$targetEl.parents('.help-bubble__row-wrapper').length === 0
77+
$targetEl.parents('.help-bubble__row-wrapper').length === 0 &&
78+
$targetEl.parents('.help-bubble').length === 0
7879
) {
7980
this.close();
8081
}

kobo/apps/audit_log/audit_actions.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,21 @@
22

33

44
class AuditAction(models.TextChoices):
5+
ARCHIVE = 'archive'
6+
AUTH = 'auth'
57
CREATE = 'create'
68
DELETE = 'delete'
9+
DEPLOY = 'deploy'
10+
DISABLE_SHARING = 'disable-sharing'
11+
ENABLE_SHARING = 'enable-sharing'
712
IN_TRASH = 'in-trash'
13+
MODIFY_SHARING = 'modify_sharing'
814
PUT_BACK = 'put-back'
15+
REDEPLOY = 'redeploy'
916
REMOVE = 'remove'
10-
UPDATE = 'update'
11-
AUTH = 'auth'
12-
DEPLOY = 'deploy'
13-
ARCHIVE = 'archive'
1417
UNARCHIVE = 'unarchive'
15-
REDEPLOY = 'redeploy'
18+
UPDATE = 'update'
19+
UPDATE_CONTENT = 'update-content'
1620
UPDATE_NAME = 'update-name'
1721
UPDATE_SETTINGS = 'update-settings'
22+
UPDATE_QA = 'update-qa'

kobo/apps/audit_log/models.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
from kpi.models import Asset
2222
from kpi.utils.log import logging
2323

24+
NEW = 'new'
25+
OLD = 'old'
26+
ADDED = 'added'
27+
REMOVED = 'removed'
28+
2429

2530
class AuditType(models.TextChoices):
2631
ACCESS = 'access'
@@ -374,16 +379,15 @@ def create_from_detail_request(cls, request):
374379
'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE,
375380
'ip_address': get_client_ip(request),
376381
'source': get_human_readable_client_user_agent(request),
382+
'latest_version_uid': updated_data['latest_version.uid']
377383
}
378384

379-
# always store the latest version uid
380-
common_metadata.update(
381-
{'latest_version_uid': updated_data['latest_version.uid']}
382-
)
383-
384385
changed_field_to_action_map = {
385386
'name': cls.name_change,
386387
'settings': cls.settings_change,
388+
'data_sharing': cls.sharing_change,
389+
'content': cls.content_change,
390+
'advanced_features.qual.qual_survey': cls.qa_change,
387391
}
388392

389393
for field, method in changed_field_to_action_map.items():
@@ -405,7 +409,7 @@ def create_from_detail_request(cls, request):
405409

406410
@staticmethod
407411
def name_change(old_field, new_field):
408-
metadata = {'name': {'old': old_field, 'new': new_field}}
412+
metadata = {'name': {OLD: old_field, NEW: new_field}}
409413
return AuditAction.UPDATE_NAME, metadata
410414

411415
@staticmethod
@@ -420,10 +424,51 @@ def settings_change(old_field, new_field):
420424
if isinstance(old, list) and isinstance(new, list):
421425
removed_values = [val for val in old if val not in new]
422426
added_values = [val for val in new if val not in old]
423-
metadata_field_subdict['added'] = added_values
424-
metadata_field_subdict['removed'] = removed_values
427+
metadata_field_subdict[ADDED] = added_values
428+
metadata_field_subdict[REMOVED] = removed_values
425429
else:
426-
metadata_field_subdict['old'] = old
427-
metadata_field_subdict['new'] = new
430+
metadata_field_subdict[OLD] = old
431+
metadata_field_subdict[NEW] = new
428432
settings[setting_name] = metadata_field_subdict
429433
return AuditAction.UPDATE_SETTINGS, {'settings': settings}
434+
435+
@staticmethod
436+
def sharing_change(old_fields, new_fields):
437+
old_enabled = old_fields.get('enabled', False)
438+
old_shared_fields = old_fields.get('fields', [])
439+
new_enabled = new_fields.get('enabled', False)
440+
new_shared_fields = new_fields.get('fields', [])
441+
shared_fields_dict = {}
442+
# anything falsy means it was disabled, anything truthy means enabled
443+
if old_enabled and not new_enabled:
444+
# sharing went from enabled to disabled
445+
action = AuditAction.DISABLE_SHARING
446+
return action, {}
447+
elif not old_enabled and new_enabled:
448+
# sharing went from disabled to enabled
449+
action = AuditAction.ENABLE_SHARING
450+
shared_fields_dict[ADDED] = new_shared_fields
451+
else:
452+
# the specific fields shared changed
453+
removed_fields = [
454+
field for field in old_shared_fields if field not in new_shared_fields
455+
]
456+
added_fields = [
457+
field for field in new_shared_fields if field not in old_shared_fields
458+
]
459+
action = AuditAction.MODIFY_SHARING
460+
shared_fields_dict[ADDED] = added_fields
461+
shared_fields_dict[REMOVED] = removed_fields
462+
return action, {'shared_fields': shared_fields_dict}
463+
464+
@staticmethod
465+
def content_change(*_):
466+
# content is too long/complicated for meaningful comparison,
467+
# so don't store values
468+
return AuditAction.UPDATE_CONTENT, {}
469+
470+
@staticmethod
471+
def qa_change(_, new_field):
472+
# qa dictionary is complicated to parse and determine
473+
# what actually changed, so just return the new dict
474+
return AuditAction.UPDATE_QA, {'qa': {NEW: new_field}}

0 commit comments

Comments
 (0)