Skip to content
Open
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,6 +1,32 @@
import { WorkspaceCodingJobDefinitionController } from './workspace-coding-job-definition.controller';

describe('WorkspaceCodingJobDefinitionController', () => {
it('lists job definitions without planned usage by default', async () => {
const jobDefinitionService = {
getJobDefinitions: jest.fn().mockResolvedValue([])
};
const controller = new WorkspaceCodingJobDefinitionController(jobDefinitionService as never);

await expect(controller.getJobDefinitions(5)).resolves.toEqual([]);

expect(jobDefinitionService.getJobDefinitions).toHaveBeenCalledWith(5, {
includePlannedUsage: false
});
});

it('lists job definitions with planned usage when requested', async () => {
const jobDefinitionService = {
getJobDefinitions: jest.fn().mockResolvedValue([])
};
const controller = new WorkspaceCodingJobDefinitionController(jobDefinitionService as never);

await expect(controller.getJobDefinitions(5, 'true')).resolves.toEqual([]);

expect(jobDefinitionService.getJobDefinitions).toHaveBeenCalledWith(5, {
includePlannedUsage: true
});
});

it('reads job definitions through the workspace-scoped service path', async () => {
const jobDefinitionService = {
getJobDefinition: jest.fn().mockResolvedValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ import {
UseGuards,
Body,
ValidationPipe,
Res
Res,
Query
} from '@nestjs/common';
import {
ApiOkResponse,
ApiParam,
ApiTags,
ApiBody,
ApiProduces
ApiProduces,
ApiQuery
} from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
Expand Down Expand Up @@ -198,6 +200,12 @@ export class WorkspaceCodingJobDefinitionController {
@UseGuards(JwtAuthGuard, WorkspaceGuard)
@ApiTags('coding')
@ApiParam({ name: 'workspace_id', type: Number })
@ApiQuery({
name: 'includePlannedUsage',
required: false,
type: Boolean,
description: 'Include expensive planned variable usage calculations.'
})
@ApiOkResponse({
description: 'List of job definitions retrieved successfully.',
schema: {
Expand Down Expand Up @@ -242,9 +250,12 @@ export class WorkspaceCodingJobDefinitionController {
}
})
async getJobDefinitions(
@WorkspaceId() workspace_id: number
@WorkspaceId() workspace_id: number,
@Query('includePlannedUsage') includePlannedUsage?: string
): Promise<JobDefinitionWithCreatedJobsCount[]> {
return this.jobDefinitionService.getJobDefinitions(workspace_id);
return this.jobDefinitionService.getJobDefinitions(workspace_id, {
includePlannedUsage: includePlannedUsage === 'true'
});
}

@Get(':workspace_id/coding/job-definitions/approved')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,31 @@ describe('CodingFreshnessService', () => {
);
});

it('normalizes mixed-case and xml-suffixed unit file ids when resolving separate coding scheme refs', async () => {
(connection.query as jest.Mock).mockResolvedValue([]);

await (
service as unknown as {
getUnitIdsByCodingSchemeRefs: (
workspaceId: number,
codingSchemeRefs: string[]
) => Promise<number[]>;
}
).getUnitIdsByCodingSchemeRefs(1, ['separate_scheme']);

const [sql, params] = (connection.query as jest.Mock).mock.calls[0];
expect(sql).toContain(
"REGEXP_REPLACE(UPPER(unit_file.file_id), '\\.XML$', '', 'i') IN"
);
expect(sql).toContain(
"REGEXP_REPLACE(UPPER(unit.name), '\\.XML$', '', 'i')"
);
expect(sql).toContain(
"REGEXP_REPLACE(UPPER(COALESCE(unit.alias, '')), '\\.XML$', '', 'i')"
);
expect(params).toEqual([1, ['SEPARATE_SCHEME']]);
});

it('marks coding scheme instruction-only changes for manual review only', async () => {
mockCodingSchemeChangeQueries([10], 12);
const responseCountsQb = queryBuilder({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1261,14 +1261,26 @@ export class CodingFreshnessService {
unit.id AS id,
REGEXP_REPLACE(UPPER(unit.name), '\\.XML$', '', 'i') AS unit_name,
REGEXP_REPLACE(UPPER(COALESCE(unit.alias, '')), '\\.XML$', '', 'i') AS unit_alias,
REGEXP_REPLACE(
UPPER(COALESCE(
(REGEXP_MATCH(unit_file.data, '<\\s*codingschemeref[^>]*>\\s*([^<]+)', 'i'))[1],
COALESCE(
NULLIF(
UPPER(unit_file.structured_data #>> '{extractedInfo,codingSchemeRefNormalized}'),
''
)),
'\\.VOCS$',
'',
'i'
),
REGEXP_REPLACE(
REGEXP_REPLACE(
UPPER(COALESCE(
NULLIF(unit_file.structured_data #>> '{extractedInfo,codingSchemeRef}', ''),
(REGEXP_MATCH(unit_file.data, '<\\s*codingschemeref[^>]*>\\s*([^<]+)', 'i'))[1],
''
)),
'\\.VOCS$',
'',
'i'
),
'\\.XML$',
'',
'i'
)
) AS scheme_ref
FROM "unit" unit
INNER JOIN booklet booklet ON booklet.id = unit.bookletid
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1409,7 +1409,34 @@ describe('JobDefinitionService', () => {
]);
});

it('attaches planned variable usage for listed definitions without created jobs', async () => {
it('does not calculate planned variable usage for listed definitions by default', async () => {
jobDefinitionRepository.find.mockResolvedValue([
{
id: 4,
workspace_id: 7,
status: 'draft',
assigned_variables: [{ unitName: 'Unit 1', variableId: 'Var 1' }],
assigned_variable_bundles: [],
max_coding_cases: 2,
case_ordering_mode: 'continuous',
distribution_seed: 'seed-4'
}
]);

const result = await service.getJobDefinitions(7);

expect(result[0]).toMatchObject({
id: 4,
plannedVariableUsage: {},
planned_variable_usage: {},
plannedVariableUsageByStatus: {},
planned_variable_usage_by_status: {}
});
expect(codingJobService.calculateDistributionVariableUsageByStatusBatch)
.not.toHaveBeenCalled();
});

it('attaches planned variable usage for listed definitions without created jobs when requested', async () => {
jobDefinitionRepository.find.mockResolvedValue([
{
id: 4,
Expand All @@ -1428,7 +1455,7 @@ describe('JobDefinitionService', () => {
[4, new Map([['Unit 1::Var 1', { regular: 2, deriveError: 0, total: 2 }]])]
]));

const result = await service.getJobDefinitions(7);
const result = await service.getJobDefinitions(7, { includePlannedUsage: true });

expect(result[0]).toMatchObject({
id: 4,
Expand Down Expand Up @@ -1485,7 +1512,7 @@ describe('JobDefinitionService', () => {
])]
]));

await service.getJobDefinitions(7);
await service.getJobDefinitions(7, { includePlannedUsage: true });

expect(codingJobService.calculateDistributionVariableUsageByStatusBatch).toHaveBeenCalledWith(7, [
expect.objectContaining({
Expand Down Expand Up @@ -1533,7 +1560,7 @@ describe('JobDefinitionService', () => {
[5, new Map([['Unit 2::Var 2', { regular: 3, deriveError: 0, total: 3 }]])]
]));

const result = await service.getJobDefinitions(7);
const result = await service.getJobDefinitions(7, { includePlannedUsage: true });

expect(codingJobService.calculateDistributionVariableUsageByStatusBatch).toHaveBeenCalledTimes(1);
expect(codingJobService.calculateDistributionVariableUsageByStatusBatch).toHaveBeenCalledWith(7, [
Expand Down
102 changes: 56 additions & 46 deletions apps/backend/src/app/database/services/jobs/job-definition.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ interface JobDefinitionForUsage {
distribution_seed?: string;
}

type GetJobDefinitionsOptions = {
includePlannedUsage?: boolean;
};

export type JobDefinitionWithCreatedJobsCount = JobDefinition & {
createdJobsCount: number;
created_jobs_count: number;
Expand Down Expand Up @@ -1158,7 +1162,8 @@ export class JobDefinitionService {
}

private async attachCreatedJobsCounts(
definitions: JobDefinition[]
definitions: JobDefinition[],
options: GetJobDefinitionsOptions = {}
): Promise<JobDefinitionWithCreatedJobsCount[]> {
const definitionsByWorkspaceId = new Map<number, JobDefinition[]>();

Expand Down Expand Up @@ -1196,53 +1201,55 @@ export class JobDefinitionService {
const plannedUsageByStatusByDefinitionId =
new Map<number, Record<string, DistributionVariableUsageByStatus>>();

const usageRequestPromises: Promise<PlannedVariableUsageBatchRequest | undefined>[] =
workspaceDefinitions.map(async definition => {
if (definition.id === undefined) {
return undefined;
}

const createdJobsCount = countsByDefinitionId.get(definition.id) || 0;
if (createdJobsCount > 0) {
plannedUsageByDefinitionId.set(definition.id, {});
plannedUsageByStatusByDefinitionId.set(definition.id, {});
return undefined;
}

const hydratedBundles = await this.hydrateVariableBundles(
definition.assigned_variable_bundles || []
);
if (options.includePlannedUsage) {
const usageRequestPromises: Promise<PlannedVariableUsageBatchRequest | undefined>[] =
workspaceDefinitions.map(async definition => {
if (definition.id === undefined) {
return undefined;
}

const createdJobsCount = countsByDefinitionId.get(definition.id) || 0;
if (createdJobsCount > 0) {
plannedUsageByDefinitionId.set(definition.id, {});
plannedUsageByStatusByDefinitionId.set(definition.id, {});
return undefined;
}

const hydratedBundles = await this.hydrateVariableBundles(
definition.assigned_variable_bundles || []
);

return this.buildPlannedVariableUsageBatchRequest(definition.id, {
id: definition.id,
assigned_variables: definition.assigned_variables || [],
assigned_variable_bundles: hydratedBundles,
max_coding_cases: definition.max_coding_cases,
case_ordering_mode: definition.case_ordering_mode,
distribution_seed: this.getDefinitionDistributionSeed(definition)
return this.buildPlannedVariableUsageBatchRequest(definition.id, {
id: definition.id,
assigned_variables: definition.assigned_variables || [],
assigned_variable_bundles: hydratedBundles,
max_coding_cases: definition.max_coding_cases,
case_ordering_mode: definition.case_ordering_mode,
distribution_seed: this.getDefinitionDistributionSeed(definition)
});
});
});
const usageRequests = (await Promise.all(usageRequestPromises))
.filter((request): request is PlannedVariableUsageBatchRequest => request !== undefined);
const usageRequests = (await Promise.all(usageRequestPromises))
.filter((request): request is PlannedVariableUsageBatchRequest => request !== undefined);

if (usageRequests.length > 0) {
const usageByDefinitionId = await this.codingJobService.calculateDistributionVariableUsageByStatusBatch(
definitionWorkspaceId,
usageRequests
);
if (usageRequests.length > 0) {
const usageByDefinitionId = await this.codingJobService.calculateDistributionVariableUsageByStatusBatch(
definitionWorkspaceId,
usageRequests
);

usageRequests.forEach(request => {
if (typeof request.key === 'number') {
const usageByStatus = this.mapVariableUsageByStatusToRecord(
usageByDefinitionId.get(request.key) || new Map()
);
plannedUsageByStatusByDefinitionId.set(request.key, usageByStatus);
plannedUsageByDefinitionId.set(
request.key,
this.mapVariableUsageByStatusRecordToTotals(usageByStatus)
);
}
});
usageRequests.forEach(request => {
if (typeof request.key === 'number') {
const usageByStatus = this.mapVariableUsageByStatusToRecord(
usageByDefinitionId.get(request.key) || new Map()
);
plannedUsageByStatusByDefinitionId.set(request.key, usageByStatus);
plannedUsageByDefinitionId.set(
request.key,
this.mapVariableUsageByStatusRecordToTotals(usageByStatus)
);
}
});
}
}

workspaceDefinitions.forEach(definition => {
Expand Down Expand Up @@ -1296,7 +1303,10 @@ export class JobDefinitionService {
return definitions as JobDefinitionWithCreatedJobsCount[];
}

async getJobDefinitions(workspaceId?: number): Promise<JobDefinitionWithCreatedJobsCount[]> {
async getJobDefinitions(
workspaceId?: number,
options: GetJobDefinitionsOptions = {}
): Promise<JobDefinitionWithCreatedJobsCount[]> {
const whereClause = workspaceId ? {
workspace_id: workspaceId
} : {};
Expand All @@ -1310,7 +1320,7 @@ export class JobDefinitionService {
await this.hydrateAssignedVariableBundles(definition);
}

return this.attachCreatedJobsCounts(definitions);
return this.attachCreatedJobsCounts(definitions, options);
}

private async assertJobDefinitionHasNoBlockingCreatedJobs(jobDefinition: JobDefinition): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ export class WorkspaceFileParsingService {
result.variables = variables;
}

const codingSchemeRefs = xmlDocument('CodingSchemeRef, codingSchemeRef')
.map((_, element) => xmlDocument(element).text().trim())
.get()
.filter(Boolean);
if (codingSchemeRefs.length > 0) {
const normalizedRefs = codingSchemeRefs.map(ref => ref
.toUpperCase()
.replace(/\.VOCS$/i, '')
.replace(/\.XML$/i, ''));
result.codingSchemeRef = codingSchemeRefs[0].toUpperCase();
result.codingSchemeRefNormalized = normalizedRefs[0];
result.codingSchemeRefs = Array.from(new Set(normalizedRefs));
}

const definitions = xmlDocument('Definition');
if (definitions.length) {
const definitionsArray: Array<Record<string, string>> = [];
Expand Down
Loading