Skip to content

Commit

Permalink
[FEATURE] Permettre aux prescripteurs de télécharger les attestations…
Browse files Browse the repository at this point in the history
… de leurs élèves (PIX-13827)

 #10531
  • Loading branch information
pix-service-auto-merge authored Nov 15, 2024
2 parents 059c525 + 1533294 commit 2af419c
Show file tree
Hide file tree
Showing 27 changed files with 558 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ORGANIZATION_FEATURE } from '../../src/shared/domain/constants.js';

const up = async function (knex) {
await knex('features').insert({
key: ORGANIZATION_FEATURE.ATTESTATIONS_MANAGEMENT.key,
description: ORGANIZATION_FEATURE.ATTESTATIONS_MANAGEMENT.description,
});
};

const down = async function (knex) {
await knex('features').where({ key: ORGANIZATION_FEATURE.ATTESTATIONS_MANAGEMENT.key }).delete();
};

export { down, up };
2 changes: 2 additions & 0 deletions api/db/seeds/data/common/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const FEATURE_LEARNER_IMPORT_ID = COMMON_OFFSET_ID + 5;
const FEATURE_CAN_REGISTER_FOR_A_COMPLEMENTARY_CERTIFICATION_ALONE_ID = COMMON_OFFSET_ID + 6;
const FEATURE_CAMPAIGN_EXTERNAL_ID = COMMON_OFFSET_ID + 7;
const FEATURE_ORALIZATION_ID = COMMON_OFFSET_ID + 8;
const FEATURE_ATTESTATIONS_MANAGEMENT_ID = COMMON_OFFSET_ID + 9;

//ORGANIZATIONS
const SCO_MANAGING_ORGANIZATION_ID = COMMON_OFFSET_ID;
Expand Down Expand Up @@ -80,6 +81,7 @@ export {
CFA_TAG,
COLLEGE_TAG,
DEFAULT_PASSWORD,
FEATURE_ATTESTATIONS_MANAGEMENT_ID,
FEATURE_CAMPAIGN_EXTERNAL_ID,
FEATURE_CAN_REGISTER_FOR_A_COMPLEMENTARY_CERTIFICATION_ALONE_ID,
FEATURE_COMPUTE_ORGANIZATION_LEARNER_CERTIFICABILITY_ID,
Expand Down
6 changes: 6 additions & 0 deletions api/db/seeds/data/common/feature-builder.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CERTIFICATION_FEATURES } from '../../../../src/certification/shared/domain/constants.js';
import { CAMPAIGN_FEATURES, ORGANIZATION_FEATURE } from '../../../../src/shared/domain/constants.js';
import {
FEATURE_ATTESTATIONS_MANAGEMENT_ID,
FEATURE_CAMPAIGN_EXTERNAL_ID,
FEATURE_CAN_REGISTER_FOR_A_COMPLEMENTARY_CERTIFICATION_ALONE_ID,
FEATURE_COMPUTE_ORGANIZATION_LEARNER_CERTIFICABILITY_ID,
Expand All @@ -27,6 +28,11 @@ const featuresBuilder = async function ({ databaseBuilder }) {
key: ORGANIZATION_FEATURE.PLACES_MANAGEMENT.key,
description: ORGANIZATION_FEATURE.PLACES_MANAGEMENT.description,
});
databaseBuilder.factory.buildFeature({
id: FEATURE_ATTESTATIONS_MANAGEMENT_ID,
key: ORGANIZATION_FEATURE.ATTESTATIONS_MANAGEMENT.key,
description: ORGANIZATION_FEATURE.ATTESTATIONS_MANAGEMENT.description,
});
databaseBuilder.factory.buildFeature({
id: FEATURE_MISSIONS_MANAGEMENT_ID,
key: ORGANIZATION_FEATURE.MISSIONS_MANAGEMENT.key,
Expand Down
2 changes: 2 additions & 0 deletions api/db/seeds/data/common/organization-builder.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AGRICULTURE_TAG,
DEFAULT_PASSWORD,
FEATURE_ATTESTATIONS_MANAGEMENT_ID,
FEATURE_COMPUTE_ORGANIZATION_LEARNER_CERTIFICABILITY_ID,
FEATURE_LEARNER_IMPORT_ID,
FEATURE_MULTIPLE_SENDING_ASSESSMENT_ID,
Expand Down Expand Up @@ -31,6 +32,7 @@ async function _createScoOrganization(databaseBuilder) {
features: [
{ id: FEATURE_COMPUTE_ORGANIZATION_LEARNER_CERTIFICABILITY_ID },
{ id: FEATURE_MULTIPLE_SENDING_ASSESSMENT_ID },
{ id: FEATURE_ATTESTATIONS_MANAGEMENT_ID },
],
});

Expand Down
4 changes: 4 additions & 0 deletions api/src/shared/domain/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const ORGANIZATION_FEATURE = {
key: 'PLACES_MANAGEMENT',
description: "Permet l'affichage de la page de gestion des places sur PixOrga",
},
ATTESTATIONS_MANAGEMENT: {
key: 'ATTESTATIONS_MANAGEMENT',
description: "Permet l'affichage de la page attestations sur PixOrga",
},
MULTIPLE_SENDING_ASSESSMENT: {
key: 'MULTIPLE_SENDING_ASSESSMENT',
description: "Permet d'activer l'envoi multiple sur les campagnes d'évaluation",
Expand Down
51 changes: 51 additions & 0 deletions orga/app/components/attestations/sixth-grade.gjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import PixButton from '@1024pix/pix-ui/components/pix-button';
import PixMultiSelect from '@1024pix/pix-ui/components/pix-multi-select';
import { on } from '@ember/modifier';
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { t } from 'ember-intl';

export default class AttestationsSixthGrade extends Component {
@tracked selectedDivisions = [];

@action
onSubmit(event) {
event.preventDefault();

this.args.onSubmit(this.selectedDivisions);
}

@action
onSelectDivision(value) {
this.selectedDivisions = value;
}

get isDisabled() {
return !this.selectedDivisions.length;
}

<template>
<h1 class="attestations-page__title">{{t "pages.attestations.title"}}</h1>

<p class="attestations-page__text">
{{t "pages.attestations.description"}}
</p>

<form class="attestations-page__action" {{on "submit" this.onSubmit}}>
<PixMultiSelect
@isSearchable={{true}}
@options={{@divisions}}
@values={{this.selectedDivisions}}
@onChange={{this.onSelectDivision}}
@placeholder={{t "pages.attestations.placeholder"}}
>
<:label>{{t "pages.attestations.select-label"}}</:label>
<:default as |option|>{{option.label}}</:default>
</PixMultiSelect>
<PixButton @type="submit" id="download_attestations" @size="small" @isDisabled={{this.isDisabled}}>
{{t "pages.attestations.download-attestations-button"}}
</PixButton>
</form>
</template>
}
8 changes: 8 additions & 0 deletions orga/app/components/layout/sidebar.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@
{{t "navigation.main.certifications"}}
</LinkTo>
{{/if}}
{{#if this.shouldDisplayAttestationsEntry}}
<LinkTo @route="authenticated.attestations" class="sidebar-nav__item">
<span class="sidebar-nav__item-icon">
<PixIcon @name="assignment" role="none" />
</span>
{{t "navigation.main.attestations"}}
</LinkTo>
{{/if}}
{{#if this.shouldDisplayMissionsEntry}}
<LinkTo @route="authenticated.missions" class="sidebar-nav__item">
<span class="sidebar-nav__item-icon">
Expand Down
4 changes: 4 additions & 0 deletions orga/app/components/layout/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export default class SidebarMenu extends Component {
return this.currentUser.isAdminInOrganization && this.currentUser.isSCOManagingStudents;
}

get shouldDisplayAttestationsEntry() {
return this.currentUser.canAccessAttestationsPage;
}

get shouldDisplayPlacesEntry() {
return this.currentUser.canAccessPlacesPage;
}
Expand Down
31 changes: 31 additions & 0 deletions orga/app/controllers/authenticated/attestations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { service } from '@ember/service';

export const SIXTH_GRADE_ATTESTATION_KEY = 'SIXTH_GRADE';
export const SIXTH_GRADE_ATTESTATION_FILE_NAME = 'attestations';

export default class AuthenticatedAttestationsController extends Controller {
@service fileSaver;
@service session;
@service currentUser;
@service notifications;

@action
async downloadSixthGradeAttestationsFile(selectedDivisions) {
try {
const organizationId = this.currentUser.organization.id;
const formatedDivisionsForQuery = selectedDivisions
.map((division) => `divisions[]=${encodeURIComponent(division)}`)
.join('&');

const url = `/api/organizations/${organizationId}/attestations/${SIXTH_GRADE_ATTESTATION_KEY}?${formatedDivisionsForQuery}`;

const token = this.session.isAuthenticated ? this.session.data.authenticated.access_token : '';

await this.fileSaver.save({ url, token, fileName: SIXTH_GRADE_ATTESTATION_FILE_NAME });
} catch (error) {
this.notifications.sendError(error.message, { autoClear: false });
}
}
}
5 changes: 5 additions & 0 deletions orga/app/models/prescriber.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default class Prescriber extends Model {

static get featureList() {
return {
ATTESTATIONS_MANAGEMENT: 'ATTESTATIONS_MANAGEMENT',
MULTIPLE_SENDING_ASSESSMENT: 'MULTIPLE_SENDING_ASSESSMENT',
COMPUTE_ORGANIZATION_LEARNER_CERTIFICABILITY: 'COMPUTE_ORGANIZATION_LEARNER_CERTIFICABILITY',
PLACES_MANAGEMENT: 'PLACES_MANAGEMENT',
Expand Down Expand Up @@ -41,6 +42,10 @@ export default class Prescriber extends Model {
return this.features[Prescriber.featureList.PLACES_MANAGEMENT];
}

get attestationsManagement() {
return this.features[Prescriber.featureList.ATTESTATIONS_MANAGEMENT];
}

get missionsManagement() {
return this.features[Prescriber.featureList.MISSIONS_MANAGEMENT];
}
Expand Down
1 change: 1 addition & 0 deletions orga/app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Router.map(function () {
});
});
this.route('certifications');
this.route('attestations');
this.route('preselect-target-profile', { path: '/selection-sujets' });
this.route('places');
this.route('missions', function () {
Expand Down
19 changes: 19 additions & 0 deletions orga/app/routes/authenticated/attestations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class AuthenticatedAttestationsRoute extends Route {
@service currentUser;
@service router;

beforeModel() {
if (!this.currentUser.canAccessAttestationsPage) {
this.router.replaceWith('application');
}
}

async model() {
const divisions = await this.currentUser.organization.divisions;
const options = divisions.map(({ name }) => ({ label: name, value: name }));
return { options };
}
}
4 changes: 4 additions & 0 deletions orga/app/services/current-user.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export default class CurrentUserService extends Service {
);
}

get canAccessAttestationsPage() {
return this.prescriber.attestationsManagement;
}

get canAccessPlacesPage() {
return this.isAdminInOrganization && this.prescriber.placesManagement;
}
Expand Down
7 changes: 4 additions & 3 deletions orga/app/services/file-saver.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default class FileSaverService extends Service {
async save({
url,
token,
fileName,
fetcher = _fetchData,
downloadFileForIEBrowser = _downloadFileForIEBrowser,
downloadFileForModernBrowsers = _downloadFileForModernBrowsers,
Expand All @@ -18,14 +19,14 @@ export default class FileSaverService extends Service {
throw jsonResponse.errors;
}

const fileName = _getFileNameFromHeader(response.headers);
const newFileName = fileName ?? _getFileNameFromHeader(response.headers);
const fileContent = await response.blob();

const browserIsInternetExplorer = window.document.documentMode;

browserIsInternetExplorer
? downloadFileForIEBrowser({ fileContent, fileName })
: downloadFileForModernBrowsers({ fileContent, fileName });
? downloadFileForIEBrowser({ fileContent, fileName: newFileName })
: downloadFileForModernBrowsers({ fileContent, fileName: newFileName });
}

get locale() {
Expand Down
1 change: 1 addition & 0 deletions orga/app/styles/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
@import 'pages/join';
@import 'pages/join-request';
@import 'pages/authenticated';
@import 'pages/authenticated/attestations';
@import 'pages/authenticated/campaigns/campaign';
@import 'pages/authenticated/campaigns/create-form';
@import 'pages/authenticated/campaigns/new';
Expand Down
24 changes: 24 additions & 0 deletions orga/app/styles/pages/authenticated/attestations.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.attestations-page {
display: flex;
flex-direction: column;

&__title {
@extend %pix-title-m;

margin: var(--pix-spacing-8x) 0;
}

&__text {
@extend %pix-body-m;

margin-bottom: var(--pix-spacing-8x);

}

&__action {
display: flex;
flex-wrap: wrap;
gap: var(--pix-spacing-4x);
align-items: end
}
}
5 changes: 5 additions & 0 deletions orga/app/templates/authenticated/attestations.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{page-title (t "pages.attestations.title")}}

<div class="attestations-page">
<Attestations::SixthGrade @divisions={{@model.options}} @onSubmit={{this.downloadSixthGradeAttestationsFile}} />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render } from '@1024pix/ember-testing-library';
import { click } from '@ember/test-helpers';
import { t } from 'ember-intl/test-support';
import SixthGrade from 'pix-orga/components/attestations/sixth-grade';
import { module, test } from 'qunit';
import sinon from 'sinon';

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

module('Integration | Component | Attestations | Sixth-grade', function (hooks) {
setupIntlRenderingTest(hooks);

test('it should display all basics informations', async function (assert) {
// given
const onSubmit = sinon.stub();
const divisions = [];

// when
const screen = await render(<template><SixthGrade @divisions={{divisions}} @onSubmit={{onSubmit}} /></template>);
// then
assert.ok(screen.getByRole('heading', { name: t('pages.attestations.title') }));
assert.ok(screen.getByText(t('pages.attestations.description')));
assert.ok(screen.getByRole('textbox', { name: t('pages.attestations.select-label') }));
assert.ok(screen.getByPlaceholderText(t('pages.attestations.placeholder')));
assert.ok(screen.getByRole('button', { name: t('pages.attestations.download-attestations-button') }));
});

test('download button is disabled if there is no selected divisions', async function (assert) {
// given
const onSubmit = sinon.stub();
const divisions = [];

// when
const screen = await render(<template><SixthGrade @divisions={{divisions}} @onSubmit={{onSubmit}} /></template>);

// then
const downloadButton = await screen.getByRole('button', {
name: t('pages.attestations.download-attestations-button'),
});
assert.dom(downloadButton).isDisabled();
});

test('it should call onSubmit action with selected divisions', async function (assert) {
// given
const onSubmit = sinon.stub();

const divisions = [{ label: 'division1', value: 'division1' }];

// when
const screen = await render(<template><SixthGrade @divisions={{divisions}} @onSubmit={{onSubmit}} /></template>);

const multiSelect = await screen.getByRole('textbox', { name: t('pages.attestations.select-label') });
await click(multiSelect);

const firstDivisionOption = await screen.findByRole('checkbox', { name: 'division1' });
await click(firstDivisionOption);

const downloadButton = await screen.getByRole('button', {
name: t('pages.attestations.download-attestations-button'),
});

// we need to get out of input choice to click on download button, so we have to click again on the multiselect to close it
await click(multiSelect);
await click(downloadButton);

// then
sinon.assert.calledWithExactly(onSubmit, ['division1']);
assert.ok(true);
});
});
Loading

0 comments on commit 2af419c

Please sign in to comment.