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

Added button to down users as a CSV file #596

Merged
merged 2 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package org.obiba.agate.service;

import jakarta.inject.Inject;
import org.joda.time.DateTime;
import org.obiba.agate.domain.Configuration;
import org.obiba.agate.domain.User;
import org.springframework.stereotype.Service;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
Expand All @@ -10,13 +16,6 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;

import jakarta.inject.Inject;

import org.joda.time.DateTime;
import org.obiba.agate.domain.Configuration;
import org.obiba.agate.domain.User;
import org.springframework.stereotype.Service;

@Service
public class UserCsvService {
private ConfigurationService configurationService;
Expand Down Expand Up @@ -54,11 +53,11 @@ private List<String> getLines(List<User> users) {
}};

return Stream.concat(
initList.stream(),
users.stream()
.map(user -> this.extractFieldsFromUser(user, header))
.map(this::convertToLine))
.collect(Collectors.toList());
initList.stream(),
users.stream()
.map(user -> this.extractFieldsFromUser(user, header))
.map(this::convertToLine))
.collect(Collectors.toList());
}

private List<String> getHeaderFromConfiguration() {
Expand All @@ -69,15 +68,18 @@ private List<String> getHeaderFromConfiguration() {
add("firstName");
add("lastName");
add("email");
add("preferredLanguage");
add("status");
add("2FA");
add("groups");
add("applications");
add("preferredLanguage");
add("lastLogin");
}};

return Stream.concat(
headerNameList.stream(),
configuration.getUserAttributes().stream().map(attr -> "attribute." + attr.getName()))
.collect(Collectors.toList());
headerNameList.stream(),
configuration.getUserAttributes().stream().map(attr -> "attribute." + attr.getName()))
.collect(Collectors.toList());
}

private List<String> extractFieldsFromUser(User user, List<String> headerFromConfiguration) {
Expand All @@ -86,19 +88,22 @@ private List<String> extractFieldsFromUser(User user, List<String> headerFromCon
add(user.getFirstName());
add(user.getLastName());
add(user.getEmail());
add(user.getPreferredLanguage());
add(user.getStatus().toString().toUpperCase());
add(Boolean.toString(user.hasOtp()));
add(user.getGroups().stream().sorted().collect(Collectors.joining(" | ")));
add(user.getApplications().stream().sorted().collect(Collectors.joining(" | ")));
add(user.getPreferredLanguage());
add(Optional.ofNullable(user.getLastLogin()).map(DateTime::toString).orElse(""));
}};

return Stream.concat(
data.stream(),
headerFromConfiguration.stream()
.filter(header -> header.startsWith("attribute."))
.map(header -> header.replace("attribute.", ""))
.map(header -> user.getAttributes().getOrDefault(header, ""))
.map(this::escapeSpecialCharacters))
.collect(Collectors.toList());
data.stream(),
headerFromConfiguration.stream()
.filter(header -> header.startsWith("attribute."))
.map(header -> header.replace("attribute.", ""))
.map(header -> user.getAttributes().getOrDefault(header, ""))
.map(this::escapeSpecialCharacters))
.collect(Collectors.toList());
}

private String escapeSpecialCharacters(String data) {
Expand Down
40 changes: 37 additions & 3 deletions agate-ui/src/components/UsersTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<q-table :rows="users" flat row-key="name" :columns="columns" :pagination="initialPagination">
<template v-slot:top-left>
<q-btn size="sm" icon="add" color="primary" :label="t('add')" @click="onAdd" />
<q-btn class="q-ml-sm" color="secondary" icon="file_download" size="sm" :disable="users.length < 1" @click="onDownload" />
</template>
<template v-slot:top-right>
<q-input v-model="filter" debounce="300" :placeholder="t('search')" dense clearable class="q-mr-md">
Expand Down Expand Up @@ -165,6 +166,7 @@
</template>

<script setup lang="ts">
import { type QTableColumn, exportFile } from 'quasar';
import type { UserDto } from 'src/models/Agate';
import UserDialog from 'src/components/UserDialog.vue';
import UpdatePasswordDialog from 'src/components/UpdatePasswordDialog.vue';
Expand Down Expand Up @@ -193,12 +195,12 @@ const selected = ref();

const users = computed(
() =>
userStore.users?.filter((usr) => {
const str = `${usr.name} ${usr.firstName || ''} ${usr.lastName || ''} ${usr.email}`;
userStore.users?.filter((user) => {
const str: string = `${user.name || ''} ${user.firstName || ''} ${user.lastName || ''} ${user.email || ''} || ${user.status || ''} || ${user.role || ''} || ${(user.groups || []).join(' ')} || ${(user.applications || []).join(' ')}`;
return filter.value ? str.toLowerCase().includes(filter.value.toLowerCase()) : true;
}) || [],
);
const columns = computed(() => [
const columns = computed<QTableColumn[]>(() => [
{ name: 'name', label: t('name'), field: 'name', align: DefaultAlignment, sortable: true },
{ name: 'fullName', label: t('fullName'), field: 'fullName', align: DefaultAlignment, sortable: true },
{ name: 'email', label: t('email'), field: 'email', align: DefaultAlignment, sortable: true },
Expand Down Expand Up @@ -266,6 +268,38 @@ function onAdd() {
showEdit.value = true;
}

function formatColumnValue(fieldName: string, row: UserDto): string {
const value = row[fieldName as keyof UserDto] || '';
const applications = (row.applications || []).concat((row.groupApplications || []).map((app) => app.application));

switch (fieldName) {
case 'fullName':
return `"${row.firstName || ''} ${row.lastName || ''}"`.trim();
case 'groups':
return Array.isArray(value) ? `"${value.join(', ')}"` : `"${value}"`;
case 'applications':
return `"${applications.map((app) => applicationStore.getApplicationName(app)).join(', ')}"`;
case 'otpEnabled':
return value ? t('enabled') : t('disabled');
case 'status':
return t(`user.status.${value}`);
default:
return typeof value === 'string' ? `"${value}"` : String(value);
}
}
function onDownload() {
const content = [columns.value.map((col) => col.label)]
.concat(users.value.map((row) => columns.value.map((col) => formatColumnValue(col.field as string, row))))
.map((row) => row.join(','))
.join('\n');

const status = exportFile('table-export.csv', content, 'text/csv');

if (status !== true) {
notifyError(t('export_error'));
}
}

function onSaved() {
refresh();
}
Expand Down
3 changes: 3 additions & 0 deletions agate-ui/src/i18n/en/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default {
copy_password: 'Copy password',
disable_2fa: 'Disable 2FA',
edit: 'Edit user',
export_error: 'Failed to export users',
firstName: 'First Name',
generate_password: 'Generate password',
groups_hint: 'Users can be members of groups to access applications associated to these groups.',
Expand Down Expand Up @@ -319,6 +320,7 @@ export default {
content_management: 'Content Management',
delete: 'Remove',
description: 'Description',
disabled: 'Disabled',
docs: 'Docs',
documentation_cookbook: 'Documentation and cookbook',
duplicate: 'Duplicate',
Expand All @@ -327,6 +329,7 @@ export default {
email_invalid: 'Invalid email',
email_required: 'Email is required',
email: 'Email',
enabled: 'Enabled',
fullName: 'Full Name',
groups_caption: 'Manage groups, grant applications access to group members',
groups: 'Groups',
Expand Down
3 changes: 3 additions & 0 deletions agate-ui/src/i18n/fr/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default {
copy_password: 'Copier le mot de passe',
disable_2fa: 'Désactiver le 2FA',
edit: "Modifier l'usager",
export_error: 'Échec de l\'exportation des usagers',
firstName: 'Prénom',
generate_password: 'Générer un mot de passe',
groups_hint: 'Les usagers peuvent être membres de groupes pour accéder aux applications associées à ces groupes.',
Expand Down Expand Up @@ -319,6 +320,7 @@ export default {
confirm: 'Confirmer',
delete: 'Supprimer',
description: 'Description',
disabled: 'Désactivé',
docs: 'Docs',
documentation_cookbook: 'Documentation & recettes',
duplicate: 'Dupliquer',
Expand All @@ -327,6 +329,7 @@ export default {
email_invalid: 'Le courriel est invalide',
email_required: 'Le courriel est requis',
email: 'Courriel',
enabled: 'Activé',
fullName: 'Nom complet',
groups_caption: 'Gérer les groupes et leurs membres, les accès aux applications',
groups: 'Groupes',
Expand Down
7 changes: 6 additions & 1 deletion agate-ui/src/stores/user.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { api } from 'src/boot/api';
import { api, baseUrl } from 'src/boot/api';
import type { UserDto } from 'src/models/Agate';

export const useUserStore = defineStore('user', () => {
Expand Down Expand Up @@ -47,6 +47,10 @@ export const useUserStore = defineStore('user', () => {
return user.id ? api.put(`/user/${user.id}`, user) : api.post('/users', { password, user });
}

function download() {
window.open(`${baseUrl}/users/_csv`, '_self');
}

function generatePassword(length: number = 12): string {
if (length < 8) {
throw new Error('Password length should be at least 8 characters for strength.');
Expand Down Expand Up @@ -90,5 +94,6 @@ export const useUserStore = defineStore('user', () => {
generatePassword,
updatePassword,
disableOTP,
download,
};
});