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] Rendre autonome le métier pour gérer la liste blanche des centres de certification fermés. #10176

Merged
merged 6 commits into from
Oct 4, 2024
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
2 changes: 2 additions & 0 deletions admin/app/components/administration/certification/index.gjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import CertificationScoringConfiguration from './certification-scoring-configuration';
import CompetenceScoringConfiguration from './competence-scoring-configuration';
import FlashAlgorithmConfiguration from './flash-algorithm-configuration';
import ScoWhitelistConfiguration from './sco-whitelist-configuration';
import ScoringSimulator from './scoring-simulator';

<template>
<ScoWhitelistConfiguration />
<CertificationScoringConfiguration />
<CompetenceScoringConfiguration />
<FlashAlgorithmConfiguration @model={{@model}} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import PixButtonUpload from '@1024pix/pix-ui/components/pix-button-upload';
import PixMessage from '@1024pix/pix-ui/components/pix-message';
import { action } from '@ember/object';
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { t } from 'ember-intl';
import ENV from 'pix-admin/config/environment';

import AdministrationBlockLayout from '../block-layout';

export default class ScoWhitelistConfiguration extends Component {
@service intl;
@service session;
@service notifications;

@action
async importScoWhitelist(files) {
this.notifications.clearAll();
try {
const fileContent = files[0];

const token = this.session.data.authenticated.access_token;
const response = await window.fetch(`${ENV.APP.API_HOST}/api/admin/sco-whitelist`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'text/csv',
Accept: 'application/json',
},
method: 'POST',
body: fileContent,
});
if (response.ok) {
this.notifications.success(this.intl.t('pages.administration.certification.sco-whitelist.import.success'));
} else {
this.notifications.error(this.intl.t('pages.administration.certification.sco-whitelist.import.error'));
}
} catch (error) {
this.notifications.error(this.intl.t('pages.administration.certification.sco-whitelist.import.error'));
} finally {
this.isLoading = false;
}
}

<template>
<AdministrationBlockLayout @title={{t "pages.administration.certification.sco-whitelist.title"}}>
<PixMessage @type="warning">Feature en cours de construction</PixMessage>
AndreiaPena marked this conversation as resolved.
Show resolved Hide resolved
<br />
<PixButtonUpload
@id="sco-whitelist-file-upload"
@onChange={{this.importScoWhitelist}}
@variant="secondary"
accept=".csv"
>
{{t "pages.administration.certification.sco-whitelist.import.button"}}
</PixButtonUpload>
</AdministrationBlockLayout>
</template>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import NotificationContainer from '@1024pix/ember-cli-notifications/components/notification-container';
import { render } from '@1024pix/ember-testing-library';
import Service from '@ember/service';
import { triggerEvent } from '@ember/test-helpers';
import { t } from 'ember-intl/test-support';
import ScoWhitelistConfiguration from 'pix-admin/components/administration/certification/sco-whitelist-configuration';
import ENV from 'pix-admin/config/environment';
import { module, test } from 'qunit';
import sinon from 'sinon';

import setupIntlRenderingTest from '../../../../helpers/setup-intl-rendering';

const accessToken = 'An access token';
const fileContent = 'foo';
const file = new Blob([fileContent], { type: `valid-file` });

module('Integration | Component | administration/certification/sco-whitelist-configuration', function (hooks) {
setupIntlRenderingTest(hooks);

let fetchStub;

hooks.beforeEach(function () {
class SessionService extends Service {
data = { authenticated: { access_token: accessToken } };
}
this.owner.register('service:session', SessionService);

fetchStub = sinon.stub(window, 'fetch');
});

hooks.afterEach(function () {
window.fetch.restore();
});

module('when import succeeds', function (hooks) {
hooks.beforeEach(function () {
fetchStub
.withArgs(`${ENV.APP.API_HOST}/api/admin/sco-whitelist`, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'text/csv',
Accept: 'application/json',
},
method: 'POST',
body: file,
})
.resolves(fetchResponse({ status: 201 }));
});

test('it displays a success notification', async function (assert) {
// when
const screen = await render(<template><ScoWhitelistConfiguration /><NotificationContainer /></template>);
const input = await screen.getByLabelText(t('pages.administration.certification.sco-whitelist.import.button'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

les get n'ont pas besoin d'await, ils ne retournent pas de promise. Les find oui.

Suggested change
const input = await screen.getByLabelText(t('pages.administration.certification.sco-whitelist.import.button'));
const input = screen.getByLabelText(t('pages.administration.certification.sco-whitelist.import.button'));

await triggerEvent(input, 'change', { files: [file] });

// then
assert.ok(await screen.findByText(t('pages.administration.certification.sco-whitelist.import.success')));
});
});

module('when import fails', function () {
test('it displays an error notification', async function (assert) {
// given
fetchStub
.withArgs(`${ENV.APP.API_HOST}/api/admin/sco-whitelist`, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'text/csv',
Accept: 'application/json',
},
method: 'POST',
body: file,
})
.rejects();
// when
const screen = await render(<template><ScoWhitelistConfiguration /><NotificationContainer /></template>);
const input = await screen.findByLabelText(t('pages.administration.certification.sco-whitelist.import.button'));
await triggerEvent(input, 'change', { files: [file] });

// then
assert.ok(await screen.findByText(t('pages.administration.certification.sco-whitelist.import.error')));
});
});
});

function fetchResponse({ body, status }) {
const mockResponse = new window.Response(JSON.stringify(body), {
status,
headers: {
'Content-type': 'application/json',
},
});

return mockResponse;
}
8 changes: 8 additions & 0 deletions admin/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,14 @@
"warmUpLength": "Nombre de questions d'entraînement"
}
},
"sco-whitelist": {
"title": "SCO whitelist",
"import": {
"button": "Import new CSV file as whitelist",
"error": "Could not save SCO whitelist",
"success": "SCO whitelist saved"
}
},
"scoring-simulator": {
"title": "Scoring simulator",
"actions": {
Expand Down
8 changes: 8 additions & 0 deletions admin/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,14 @@
"warmUpLength": "Nombre de questions d'entraînement"
}
},
"sco-whitelist": {
"title": "Liste blanche centres SCO",
"import": {
"button": "Importer une nouvelle liste blanche au format CSV",
"error": "Échec de l'enregistrement de la liste blanche",
"success": "Liste blanche enregistrée"
}
},
"scoring-simulator": {
"title": "Simulateur de scoring",
"actions": {
Expand Down
2 changes: 2 additions & 0 deletions api/db/database-builder/factory/build-certification-center.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const buildCertificationCenter = function ({
createdAt = new Date('2020-01-01'),
updatedAt,
isV3Pilot = false,
isScoBlockedAccessWhitelist = false,
} = {}) {
const values = {
id,
Expand All @@ -17,6 +18,7 @@ const buildCertificationCenter = function ({
createdAt,
updatedAt,
isV3Pilot,
isScoBlockedAccessWhitelist,
};
return databaseBuffer.pushInsertable({
tableName: 'certification-centers',
Expand Down
21 changes: 21 additions & 0 deletions api/db/migrations/20240910144200_certification_whitelist.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const TABLE_NAME = 'certification-centers';
const COLUMN_NAME = 'isScoBlockedAccessWhitelist';

const up = async function (knex) {
await knex.schema.table(TABLE_NAME, function (table) {
table
.boolean(COLUMN_NAME)
.defaultTo(false)
.comment(
'As of now, the center is currently eligible to SCO access closure when the property is set to false. Otherwise, the center is whitelisted (not closed)',
);
});
};

const down = async function (knex) {
await knex.schema.table(TABLE_NAME, function (table) {
table.dropColumn(COLUMN_NAME);
});
};

export { down, up };
3 changes: 2 additions & 1 deletion api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
attachTargetProfileRoutes,
complementaryCertificationRoutes,
} from './src/certification/complementary-certification/routes.js';
import { certificationConfigurationRoutes } from './src/certification/configuration/routes.js';
import { certificationConfigurationRoutes, scoWhitelistRoutes } from './src/certification/configuration/routes.js';
import { certificationEnrolmentRoutes } from './src/certification/enrolment/routes.js';
import { flashCertificationRoutes } from './src/certification/flash-certification/routes.js';
import { certificationResultRoutes } from './src/certification/results/routes.js';
Expand Down Expand Up @@ -46,6 +46,7 @@ const certificationRoutes = [
certificationSessionRoutes,
complementaryCertificationRoutes,
scoringRoutes,
scoWhitelistRoutes,
];

const prescriptionRoutes = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { usecases } from '../domain/usecases/index.js';
import { extractExternalIds } from '../infrastructure/serializers/csv/sco-whitelist-csv-parser.js';

const importScoWhitelist = async function (request, h, dependencies = { extractExternalIds }) {
const externalIds = await dependencies.extractExternalIds(request.payload.path);
await usecases.importScoWhitelist({ externalIds });
return h.response().created();
};

export const scoWhitelistController = {
importScoWhitelist,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { PayloadTooLargeError, sendJsonApiError } from '../../../shared/application/http-errors.js';
import { securityPreHandlers } from '../../../shared/application/security-pre-handlers.js';
import { scoWhitelistController } from './sco-whitelist-controller.js';

const TWENTY_MEGABYTES = 1048576 * 20;
alexandrecoin marked this conversation as resolved.
Show resolved Hide resolved

const register = async function (server) {
server.route([
{
method: 'POST',
path: '/api/admin/sco-whitelist',
config: {
pre: [
{
method: (request, h) =>
securityPreHandlers.hasAtLeastOneAccessOf([securityPreHandlers.checkAdminMemberHasRoleSuperAdmin])(
request,
h,
),
assign: 'hasAuthorizationToAccessAdminScope',
},
],
Comment on lines +13 to +22
Copy link
Member

@AndreiaPena AndreiaPena Sep 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pre: [
{
method: (request, h) =>
securityPreHandlers.hasAtLeastOneAccessOf([securityPreHandlers.checkAdminMemberHasRoleSuperAdmin])(
request,
h,
),
assign: 'hasAuthorizationToAccessAdminScope',
},
],
pre: [
{
method: securityPreHandlers.checkAdminMemberHasRoleSuperAdmin,
assign: 'hasAuthorizationToAccessAdminScope',
},
],

payload: {
maxBytes: TWENTY_MEGABYTES,
output: 'file',
failAction: (request, h) => {
return sendJsonApiError(
new PayloadTooLargeError('An error occurred, payload is too large', 'PAYLOAD_TOO_LARGE', {
maxSize: '20',
}),
h,
);
},
},
handler: scoWhitelistController.importScoWhitelist,
tags: ['api', 'admin'],
notes: [
'Cette route est restreinte aux utilisateurs authentifiés avec le rôle Super Admin',
'Elle permet de mettre a jour la liste blanche des centres SCO.',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'Elle permet de mettre a jour la liste blanche des centres SCO.',
'Elle permet de mettre à jour la liste blanche des centres SCO.',

],
},
},
]);
};

const name = 'sco-whitelist-api';
export { name, register };
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @typedef {import ('../../domain/usecases/index.js').CenterRepository} CenterRepository
*/

import { withTransaction } from '../../../../shared/domain/DomainTransaction.js';

export const importScoWhitelist = withTransaction(
/**
* @param {Object} params
* @param {CenterRepository} params.centerRepository
*/
async ({ externalIds = [], centerRepository }) => {
await centerRepository.resetWhitelist();
return centerRepository.addToWhitelistByExternalIds({ externalIds });
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,26 @@ export const findSCOV2Centers = async function ({ pageNumber = DEFAULT_PAGINATIO

return { centerIds: results.map(({ id }) => id), pagination };
};

/**
* @param {Object} params
* @param {Array<number>} params.externalIds
* @returns {Promise<void>}
*/
export const addToWhitelistByExternalIds = async ({ externalIds }) => {
const knexConn = DomainTransaction.getConnection();
return knexConn('certification-centers')
.update({ isScoBlockedAccessWhitelist: true, updatedAt: knexConn.fn.now() })
.where({ type: CERTIFICATION_CENTER_TYPES.SCO })
.whereIn('externalId', externalIds);
};

/**
* @returns {Promise<void>}
*/
export const resetWhitelist = async () => {
const knexConn = DomainTransaction.getConnection();
return knexConn('certification-centers')
.update({ isScoBlockedAccessWhitelist: false, updatedAt: knexConn.fn.now() })
.where({ type: CERTIFICATION_CENTER_TYPES.SCO });
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { CsvColumn } from '../../../../../../lib/infrastructure/serializers/csv/csv-column.js';

class ScoWhitelistCsvHeader {
constructor() {
this.columns = this.setColumns();
}

setColumns() {
return [
new CsvColumn({
property: 'externalId',
name: 'externalId',
isRequired: true,
}),
];
}
}

export { ScoWhitelistCsvHeader };
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as fs from 'node:fs/promises';

import { CsvParser } from '../../../../../shared/infrastructure/serializers/csv/csv-parser.js';
import { ScoWhitelistCsvHeader } from './sco-whitelist-csv-header.js';

export const extractExternalIds = async (file) => {
const buffer = await fs.readFile(file);
try {
return _extractIds(buffer).map(({ externalId }) => externalId.trim());
} finally {
fs.unlink(file);
}
};

const _extractIds = (buffer) => {
const columns = new ScoWhitelistCsvHeader();
const campaignIdsCsv = new CsvParser(buffer, columns);
return campaignIdsCsv.parse();
};
Loading
Loading