Skip to content

Commit

Permalink
[TECH] ajoute le campaignParticipationId dans la table knowledge-elem…
Browse files Browse the repository at this point in the history
…ent-snapshots (PIX-15755)

 #11103
  • Loading branch information
pix-service-auto-merge authored Jan 17, 2025
2 parents 20f2a0e + c0d7da8 commit 42808a6
Show file tree
Hide file tree
Showing 2 changed files with 386 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { knex } from '../../db/knex-database-connection.js';
import { CampaignTypes } from '../../src/prescription/shared/domain/constants.js';
import { Script } from '../../src/shared/application/scripts/script.js';
import { ScriptRunner } from '../../src/shared/application/scripts/script-runner.js';

const DEFAULT_CHUNK_SIZE = 10000;
const DEFAULT_PAUSE_DURATION = 2000;

const pause = async (duration) => {
return new Promise((resolve) => {
setTimeout(resolve, duration);
});
};

function getEmptyParticipationKnowlegdeElementSnapshotIds(firstId, size = DEFAULT_CHUNK_SIZE) {
return knex('knowledge-element-snapshots')
.whereNull('campaignParticipationId')
.where('id', '>=', firstId)
.orderBy('id', 'asc')
.pluck('id')
.limit(size);
}

// Définition du script
export class PopulateCampaignParticipationIdScript extends Script {
constructor() {
super({
description:
'This script will populate the column knowledge-element-snapshots.campaignParticipationId with campaign-participations.id',
permanent: false,
options: {
chunkSize: {
type: 'number',
default: DEFAULT_CHUNK_SIZE,
description: 'number of records to update in one update',
},
pauseDuration: {
type: 'number',
default: DEFAULT_PAUSE_DURATION,
description: 'Time in ms between each chunk processing',
},
},
});
}

async handle({ options, logger, dependencies = { pause } }) {
const result = await knex('knowledge-element-snapshots').count().whereNull('campaignParticipationId').first();

if (result.count === 0) {
logger.info(
{ event: 'PopulateCampaignParticipationIdScript' },
`There is no knowledge-element-snapshot with missing campaignParticipationId. Job done !`,
);
return;
} else {
logger.info(
{ event: 'PopulateCampaignParticipationIdScript' },
`Try to populate ${result.count} missing campaignParticipationId`,
);
}

let ids = await getEmptyParticipationKnowlegdeElementSnapshotIds(0, options.chunkSize);
let totalUddatedRows = 0;
while (ids.length > 0) {
const updatedRows = await knex('knowledge-element-snapshots')
.whereNull('campaignParticipationId')
.updateFrom('campaign-participations')
.update({
campaignParticipationId: knex.ref('campaign-participations.id'),
})
.where('knowledge-element-snapshots.snappedAt', knex.ref('campaign-participations.sharedAt'))
.where('knowledge-element-snapshots.userId', knex.ref('campaign-participations.userId'))
.whereIn('knowledge-element-snapshots.id', ids);

totalUddatedRows += updatedRows;
ids = await getEmptyParticipationKnowlegdeElementSnapshotIds(ids[ids.length - 1] + 1, options.chunkSize);
if (ids.length > 0 && options.pauseDuration > 0) {
await dependencies.pause(options.pauseDuration);
}
}
logger.info(
{ event: 'PopulateCampaignParticipationIdScript' },
`${totalUddatedRows} rows updated from "knowledge-element-snapshots"`,
);
let emptyRowResult = await knex('knowledge-element-snapshots').count().whereNull('campaignParticipationId').first();
if (emptyRowResult.count === 0) {
logger.info(
{ event: 'PopulateCampaignParticipationIdScript' },
`No row with empty campaignParticipationId to update. Job done !`,
);
return;
} else {
logger.info(`${emptyRowResult.count} rows with empty campaignParticipationId to update`);
}
const anonymisedParticipations = await knex('campaign-participations')
.select(['campaign-participations.id', 'sharedAt'])
.join('campaigns', function () {
this.on('campaigns.id', 'campaign-participations.campaignId').onVal('campaigns.type', CampaignTypes.ASSESSMENT);
})
.whereNull('userId')
.whereNotNull('sharedAt');

const emptyKeSnapshotParticipations = await knex('knowledge-element-snapshots')
.whereNull('campaignParticipationId')
.select(['id', 'snappedAt']);

logger.info(
{ event: 'PopulateCampaignParticipationIdScript' },
`Try to populate ${emptyKeSnapshotParticipations.length} keSnapshot from ${anonymisedParticipations.length} anonymised participations`,
);

const matchingSnapshotAndParticipations = emptyKeSnapshotParticipations.flatMap(({ id, snappedAt }) => {
const participations = anonymisedParticipations.filter(
({ sharedAt }) => snappedAt.toISOString() === sharedAt.toISOString(),
);
if (participations.length !== 1) {
return [];
}
return [{ keSnapshotId: id, campaignParticipationId: participations[0].id }];
});

await knex.transaction(async (trx) => {
for (const row of matchingSnapshotAndParticipations) {
await trx('knowledge-element-snapshots')
.update({ campaignParticipationId: row.campaignParticipationId })
.where({ id: row.keSnapshotId });
}
});

logger.info(
{ event: 'PopulateCampaignParticipationIdScript' },
`Populate ${matchingSnapshotAndParticipations.length} anonymised participations`,
);

emptyRowResult = await knex('knowledge-element-snapshots')
.select('id')
.whereNull('campaignParticipationId')
.pluck('id');

if (emptyRowResult.length === 0) {
logger.info(
{ event: 'PopulateCampaignParticipationIdScript' },
`No row with empty campaignParticipationId to update. Job done !`,
);
} else {
logger.info(
{ event: 'PopulateCampaignParticipationIdScript', ids: emptyRowResult },
`${emptyRowResult.length} rows with empty campaignParticipationId to update`,
);
}
}
}

// Exécution du script
await ScriptRunner.execute(import.meta.url, PopulateCampaignParticipationIdScript);
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { PopulateCampaignParticipationIdScript } from '../../../../scripts/prod/populate-campaign-participation-id-in-knowledge-element-snapshot.js';
import { databaseBuilder, expect, knex, sinon } from '../../../test-helper.js';

describe('Script | Prod | Delete Organization Learners From Organization', function () {
describe('Options', function () {
it('has the correct options', function () {
// when
const script = new PopulateCampaignParticipationIdScript();
const { options } = script.metaInfo;

// then
expect(options.chunkSize).to.deep.include({
type: 'number',
default: 10000,
description: 'number of records to update in one update',
});
expect(options.pauseDuration).to.deep.include({
type: 'number',
default: 2000,
description: 'Time in ms between each chunk processing',
});
});
});

describe('Handle', function () {
let script;
let logger;
let dependencies;
let user, learner, campaign, otherCampaign;

beforeEach(async function () {
script = new PopulateCampaignParticipationIdScript();
logger = { info: sinon.spy(), error: sinon.spy(), debug: sinon.spy() };
dependencies = { pause: sinon.stub() };

user = databaseBuilder.factory.buildUser({ id: 123, firstName: 'Sam', lastName: 'Sagace' });
learner = databaseBuilder.factory.buildOrganizationLearner({
userId: user.id,
});

campaign = databaseBuilder.factory.buildCampaign();
otherCampaign = databaseBuilder.factory.buildCampaign();
await databaseBuilder.commit();
});

describe('no participation', function () {
it('should end early if there no participation to update', async function () {
// when
await script.handle({ options: { chunkSize: 1, pauseDuration: 0 }, logger, dependencies });

// then
expect(logger.info).to.have.been.calledWithExactly(
{ event: 'PopulateCampaignParticipationIdScript' },
'There is no knowledge-element-snapshot with missing campaignParticipationId. Job done !',
);
});
});

describe('linked participation', function () {
beforeEach(async function () {
// given
const participation = databaseBuilder.factory.buildCampaignParticipation({
organizationLearnerId: learner.id,
userId: user.id,
campaign: campaign.id,
participantExternalId: null,
createdAt: new Date('2024-12-15'),
sharedAt: new Date('2024-12-16'),
});
databaseBuilder.factory.knowledgeElementSnapshotFactory.buildSnapshot({
id: 1,
userId: user.id,
snappedAt: participation.sharedAt,
knowledgeElementsAttributes: [{ skillId: 'skill_1', status: 'validated', earnedPix: 40 }],
});
const otherParticipation = databaseBuilder.factory.buildCampaignParticipation({
organizationLearnerId: learner.id,
userId: user.id,
campaign: otherCampaign.id,
participantExternalId: null,
createdAt: new Date('2024-05-09'),
sharedAt: new Date('2024-05-12'),
});
databaseBuilder.factory.knowledgeElementSnapshotFactory.buildSnapshot({
id: 2,
userId: user.id,
snappedAt: otherParticipation.sharedAt,
knowledgeElementsAttributes: [{ skillId: 'skill_1', status: 'validated', earnedPix: 40 }],
});
await databaseBuilder.commit();
});

it('log how many entries will be updated', async function () {
// when
await script.handle({ options: { chunkSize: 1, pauseDuration: 0 }, logger, dependencies });

// then
expect(
logger.info.calledWithExactly(
{ event: 'PopulateCampaignParticipationIdScript' },
'Try to populate 2 missing campaignParticipationId',
),
).to.be.true;
});

it('populate empty participations one by one', async function () {
// when
await script.handle({ options: { chunkSize: 1, pauseDuration: 0 }, logger, dependencies });

// then
const emptyKeSnapshots = await knex('knowledge-element-snapshots')
.whereNull('campaignParticipationId')
.count()
.first();
expect(emptyKeSnapshots.count).to.equals(0);
expect(logger.info).to.have.been.calledWith(
{ event: 'PopulateCampaignParticipationIdScript' },
`2 rows updated from "knowledge-element-snapshots"`,
);
expect(logger.info).to.have.been.calledWith(
{ event: 'PopulateCampaignParticipationIdScript' },
'No row with empty campaignParticipationId to update. Job done !',
);
});

it('populate empty participations using a chunk of 1000', async function () {
// when
await script.handle({ options: { chunkSize: 1000, pauseDuration: 0 }, logger, dependencies });

// then
const emptyKeSnapshots = await knex('knowledge-element-snapshots')
.whereNull('campaignParticipationId')
.count()
.first();
expect(emptyKeSnapshots.count).to.equals(0);
expect(logger.info).to.have.been.calledWith(
{ event: 'PopulateCampaignParticipationIdScript' },
`2 rows updated from "knowledge-element-snapshots"`,
);
expect(dependencies.pause.called).to.be.false;
expect(logger.info).to.have.been.calledWith(
{ event: 'PopulateCampaignParticipationIdScript' },
'No row with empty campaignParticipationId to update. Job done !',
);
});

it('should pause between chunk of 1', async function () {
// given
dependencies.pause.resolves();

// when
await script.handle({ options: { chunkSize: 1, pauseDuration: 10 }, logger, dependencies });
const emptyKeSnapshots = await knex('knowledge-element-snapshots')
.whereNull('campaignParticipationId')
.count()
.first();

// then
expect(emptyKeSnapshots.count).to.equals(0);
expect(dependencies.pause).to.have.been.calledOnce;
expect(dependencies.pause).to.have.been.calledWith(10);
});
});

describe('anonymised participations', function () {
it('should populate matching unique participation and snapshot', async function () {
// given
const participation = databaseBuilder.factory.buildCampaignParticipation({
organizationLearnerId: learner.id,
userId: null,
campaign: campaign.id,
participantExternalId: null,
createdAt: new Date('2024-12-15'),
sharedAt: new Date('2014-12-16'),
});

databaseBuilder.factory.knowledgeElementSnapshotFactory.buildSnapshot({
userId: user.id,
snappedAt: participation.sharedAt,
knowledgeElementsAttributes: [{ skillId: 'skill_1', status: 'validated', earnedPix: 40 }],
});
await databaseBuilder.commit();

// when
await script.handle({ options: { chunkSize: 1000, pauseDuration: 0 }, logger, dependencies });

// then
const matchingSnapshot = await knex('knowledge-element-snapshots')
.where('snappedAt', participation.sharedAt)
.first();
expect(matchingSnapshot.campaignParticipationId).to.equals(participation.id);
});

it('should not populate when matching multiple participations and snapshot', async function () {
// given
const participation = databaseBuilder.factory.buildCampaignParticipation({
organizationLearnerId: learner.id,
userId: null,
campaign: campaign.id,
participantExternalId: null,
createdAt: new Date('2024-12-15'),
sharedAt: new Date('2014-12-16'),
});
databaseBuilder.factory.buildCampaignParticipation({
userId: null,
participantExternalId: null,
createdAt: new Date('2024-12-15'),
sharedAt: participation.sharedAt,
});

databaseBuilder.factory.knowledgeElementSnapshotFactory.buildSnapshot({
userId: user.id,
snappedAt: participation.sharedAt,
knowledgeElementsAttributes: [{ skillId: 'skill_1', status: 'validated', earnedPix: 40 }],
});
await databaseBuilder.commit();

// when
await script.handle({ options: { chunkSize: 1000, pauseDuration: 0 }, logger, dependencies });

//then
const emptyKeSnapshots = await knex('knowledge-element-snapshots')
.whereNull('campaignParticipationId')
.count()
.first();

expect(emptyKeSnapshots.count).to.equals(1);
});
});
});
});

0 comments on commit 42808a6

Please sign in to comment.