Skip to content

Commit

Permalink
Merge branch 'main' into nlp-background-audio
Browse files Browse the repository at this point in the history
  • Loading branch information
p2edwards committed Oct 31, 2024
2 parents 6297850 + 13f1150 commit d3013c5
Show file tree
Hide file tree
Showing 16 changed files with 463 additions and 118 deletions.
28 changes: 27 additions & 1 deletion jsapp/js/components/activity/activityLogs.query.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {keepPreviousData, useQuery} from '@tanstack/react-query';
import type {KoboSelectOption} from 'js/components/common/koboSelect';
import type {PaginatedResponse} from 'js/dataInterface';
import type {FailResponse, PaginatedResponse} from 'js/dataInterface';
import moment from 'moment';
import {QueryKeys} from 'js/query/queryKeys';

Expand Down Expand Up @@ -70,6 +70,26 @@ const getFilterOptions = async () =>
setTimeout(() => resolve(mockOptions), 1000);
});

/**
* Starts the exporting process of the activity logs.
* @returns {Promise<void>} The promise that starts the export
*/
const startActivityLogsExport = async () =>
new Promise<void>((resolve, reject) => {
// Simulates backend export process.
setTimeout(() => {
if (Math.random() > 0.5) {
resolve();
} else {
const failResponse: FailResponse = {
status: 500,
statusText: 'Mocked error',
};
reject(failResponse);
}
}, 500);
});

/**
*
* This is a hook that fetches activity logs from the server.
Expand All @@ -94,3 +114,9 @@ export const useActivityLogsFilterOptionsQuery = () =>
queryKey: [QueryKeys.activityLogsFilter],
queryFn: () => getFilterOptions(),
});

/**
* This is a hook to start the exporting process of the activity logs.
* @returns {() => void} The function to start the export
*/
export const useExportActivityLogs = () => startActivityLogsExport;
11 changes: 6 additions & 5 deletions jsapp/js/components/activity/formActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import '../../../scss/components/_kobo.form-view.scss';
import type {KoboSelectOption} from '../common/koboSelect';
import KoboSelect from '../common/koboSelect';
import type {UniversalTableColumn} from 'jsapp/js/universalTable/universalTable.component';
import Button from '../common/button';
import PaginatedQueryUniversalTable from 'jsapp/js/universalTable/paginatedQueryUniversalTable.component';
import type {ActivityLogsItem} from './activityLogs.query';
import {
useActivityLogsFilterOptionsQuery,
useActivityLogsQuery,
useExportActivityLogs,
} from './activityLogs.query';
import styles from './formActivity.module.scss';
import cx from 'classnames';
import {formatTime} from 'jsapp/js/utils';
import Avatar from '../common/avatar';
import ExportToEmailButton from '../exportToEmailButton/exportToEmailButton.component';

const EventDescription = ({
who,
Expand Down Expand Up @@ -56,6 +57,8 @@ export default function FormActivity() {
const [selectedFilterOption, setSelectedFilterOption] =
useState<KoboSelectOption | null>(null);

const exportData = useExportActivityLogs();

const handleFilterChange = (value: string | null) => {
setSelectedFilterOption(
filterOptions?.find((option) => option.value === value) || null
Expand All @@ -78,11 +81,9 @@ export default function FormActivity() {
placeholder={t('Filter by')}
options={filterOptions || []}
/>
<Button
size='m'
type='primary'
startIcon='download'
<ExportToEmailButton
label={t('Export all data')}
exportFunction={exportData}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Button from '../common/button';
import KoboPrompt from '../modals/koboPrompt';
import {useState} from 'react';
import {handleApiFail} from 'jsapp/js/api';
import type {FailResponse} from 'jsapp/js/dataInterface';

const MessageModal = ({onClose}: {onClose: () => void}) => (
<KoboPrompt
isOpen
onRequestClose={onClose}
title={t('Exporting data')}
buttons={[
{
label: 'Ok',
onClick: onClose,
},
]}
>
{t(
"Your export request is currently being processed. Once the export is complete, you'll receive an email with all the details."
)}
</KoboPrompt>
);

/**
* Button to be used in views that export data to email.
* The button receives a label and an export function that should return a promise.
* The function is called when the button is clicked and if no error occurs, a message is shown to the user.
*/
export default function ExportToEmailButton({
exportFunction,
label,
}: {
exportFunction: () => Promise<void>;
label: string;
}) {
const [isMessageOpen, setIsMessageOpen] = useState(false);
const [isPending, setIsPending] = useState(false);

const handleClick = () => {
setIsPending(true);
exportFunction()
.then(() => {
setIsMessageOpen(true);
})
.catch((error) => handleApiFail(error as FailResponse))
.finally(() => {
setIsPending(false);
});
};

return (
<>
<Button
size='m'
type='primary'
label={label}
startIcon='download'
onClick={handleClick}
isPending={isPending}
/>
{isMessageOpen && (
<MessageModal onClose={() => setIsMessageOpen(false)} />
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type {Meta, StoryFn} from '@storybook/react';

import ExportToEmailButton from './exportToEmailButton.component';

export default {
title: 'misc/ExportToEmailButton',
component: ExportToEmailButton,
argTypes: {
label: {
control: 'text',
},
},
} as Meta<typeof ExportToEmailButton>;

const Template: StoryFn<typeof ExportToEmailButton> = (args) => (
<ExportToEmailButton {...args} />
);

export const Primary = Template.bind({});
Primary.args = {
label: 'Export all data',
exportFunction: () => new Promise((resolve) => setTimeout(resolve, 500)),
};
3 changes: 2 additions & 1 deletion jsapp/js/components/support/helpBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ class HelpBubble extends React.Component<{}, HelpBubbleState> {
$targetEl.parents('.help-bubble__popup').length === 0 &&
$targetEl.parents('.help-bubble__popup-content').length === 0 &&
$targetEl.parents('.help-bubble__row').length === 0 &&
$targetEl.parents('.help-bubble__row-wrapper').length === 0
$targetEl.parents('.help-bubble__row-wrapper').length === 0 &&
$targetEl.parents('.help-bubble').length === 0
) {
this.close();
}
Expand Down
15 changes: 10 additions & 5 deletions kobo/apps/audit_log/audit_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@


class AuditAction(models.TextChoices):
ARCHIVE = 'archive'
AUTH = 'auth'
CREATE = 'create'
DELETE = 'delete'
DEPLOY = 'deploy'
DISABLE_SHARING = 'disable-sharing'
ENABLE_SHARING = 'enable-sharing'
IN_TRASH = 'in-trash'
MODIFY_SHARING = 'modify_sharing'
PUT_BACK = 'put-back'
REDEPLOY = 'redeploy'
REMOVE = 'remove'
UPDATE = 'update'
AUTH = 'auth'
DEPLOY = 'deploy'
ARCHIVE = 'archive'
UNARCHIVE = 'unarchive'
REDEPLOY = 'redeploy'
UPDATE = 'update'
UPDATE_CONTENT = 'update-content'
UPDATE_NAME = 'update-name'
UPDATE_SETTINGS = 'update-settings'
UPDATE_QA = 'update-qa'
65 changes: 55 additions & 10 deletions kobo/apps/audit_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
from kpi.models import Asset
from kpi.utils.log import logging

NEW = 'new'
OLD = 'old'
ADDED = 'added'
REMOVED = 'removed'


class AuditType(models.TextChoices):
ACCESS = 'access'
Expand Down Expand Up @@ -374,16 +379,15 @@ def create_from_detail_request(cls, request):
'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE,
'ip_address': get_client_ip(request),
'source': get_human_readable_client_user_agent(request),
'latest_version_uid': updated_data['latest_version.uid']
}

# always store the latest version uid
common_metadata.update(
{'latest_version_uid': updated_data['latest_version.uid']}
)

changed_field_to_action_map = {
'name': cls.name_change,
'settings': cls.settings_change,
'data_sharing': cls.sharing_change,
'content': cls.content_change,
'advanced_features.qual.qual_survey': cls.qa_change,
}

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

@staticmethod
def name_change(old_field, new_field):
metadata = {'name': {'old': old_field, 'new': new_field}}
metadata = {'name': {OLD: old_field, NEW: new_field}}
return AuditAction.UPDATE_NAME, metadata

@staticmethod
Expand All @@ -420,10 +424,51 @@ def settings_change(old_field, new_field):
if isinstance(old, list) and isinstance(new, list):
removed_values = [val for val in old if val not in new]
added_values = [val for val in new if val not in old]
metadata_field_subdict['added'] = added_values
metadata_field_subdict['removed'] = removed_values
metadata_field_subdict[ADDED] = added_values
metadata_field_subdict[REMOVED] = removed_values
else:
metadata_field_subdict['old'] = old
metadata_field_subdict['new'] = new
metadata_field_subdict[OLD] = old
metadata_field_subdict[NEW] = new
settings[setting_name] = metadata_field_subdict
return AuditAction.UPDATE_SETTINGS, {'settings': settings}

@staticmethod
def sharing_change(old_fields, new_fields):
old_enabled = old_fields.get('enabled', False)
old_shared_fields = old_fields.get('fields', [])
new_enabled = new_fields.get('enabled', False)
new_shared_fields = new_fields.get('fields', [])
shared_fields_dict = {}
# anything falsy means it was disabled, anything truthy means enabled
if old_enabled and not new_enabled:
# sharing went from enabled to disabled
action = AuditAction.DISABLE_SHARING
return action, {}
elif not old_enabled and new_enabled:
# sharing went from disabled to enabled
action = AuditAction.ENABLE_SHARING
shared_fields_dict[ADDED] = new_shared_fields
else:
# the specific fields shared changed
removed_fields = [
field for field in old_shared_fields if field not in new_shared_fields
]
added_fields = [
field for field in new_shared_fields if field not in old_shared_fields
]
action = AuditAction.MODIFY_SHARING
shared_fields_dict[ADDED] = added_fields
shared_fields_dict[REMOVED] = removed_fields
return action, {'shared_fields': shared_fields_dict}

@staticmethod
def content_change(*_):
# content is too long/complicated for meaningful comparison,
# so don't store values
return AuditAction.UPDATE_CONTENT, {}

@staticmethod
def qa_change(_, new_field):
# qa dictionary is complicated to parse and determine
# what actually changed, so just return the new dict
return AuditAction.UPDATE_QA, {'qa': {NEW: new_field}}
Loading

0 comments on commit d3013c5

Please sign in to comment.