Skip to content

Commit

Permalink
feat(project): include project-related jobs in status
Browse files Browse the repository at this point in the history
  • Loading branch information
kgajowy committed Sep 15, 2021
1 parent 3a68cdf commit 6cf28ba
Show file tree
Hide file tree
Showing 12 changed files with 195 additions and 45 deletions.
28 changes: 28 additions & 0 deletions api/apps/api/src/migrations/api/1631254563649-ProjectJobsStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { QueryRunner } from 'typeorm';

export class ProjectJobsStatus1631254563649 {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE VIEW "project_job_status" as
SELECT DISTINCT ON (job_type, topic) job_type,
api_events.topic AS project_id,
api_events.kind,
api_events.data
FROM api_events
CROSS JOIN LATERAL SUBSTRING(
api_events.kind
FROM
'project.#"[^.]*#"%' FOR '#'
) AS job_type
ORDER BY job_type,
api_events.topic,
api_events.timestamp DESC;
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP VIEW "project_job_status";
`);
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { Injectable } from '@nestjs/common';
import { Scenario } from '../job-status';
import { ProjectWithScenarios } from '../job-status';
import { ProjectJobsStatusDto } from './project-jobs-status.dto';

@Injectable()
export class JobStatusSerializer {
serialize(
projectId: string,
scenarioWithJobs: Scenario[],
scenarioWithJobs: ProjectWithScenarios,
): ProjectJobsStatusDto {
return {
data: {
type: 'project-jobs',
id: projectId,
attributes: {
scenarios: scenarioWithJobs.map((scenario) => ({
jobs: scenarioWithJobs.project.jobs,
scenarios: scenarioWithJobs.scenarios.map((scenario) => ({
id: scenario.scenarioId,
jobs: scenario.jobs,
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export class ProjectStatus {
type: ScenarioStatus,
})
scenarios!: ScenarioStatus[];

@ApiProperty({
isArray: true,
type: ScenarioJobStatus,
})
jobs!: ScenarioJobStatus[];
}

export class JSONAPIProjectJobStatusData {
Expand Down
8 changes: 7 additions & 1 deletion api/apps/api/src/modules/projects/job-status/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
export { JobType } from './jobs.enum';
export { Job, Scenario, Status, JobStatusService } from './job-status.service';
export {
Job,
Scenario,
Status,
JobStatusService,
ProjectWithScenarios,
} from './job-status.service';
38 changes: 35 additions & 3 deletions api/apps/api/src/modules/projects/job-status/job-status.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Required } from 'utility-types';
import { JobStatus as Status } from '@marxan-api/modules/scenarios/scenario.api.entity';
import { assertDefined } from '@marxan/utils';
import { assertDefined, isDefined } from '@marxan/utils';
import { ScenarioJobStatus } from './job-status.view.api.entity';
import { JobType } from './jobs.enum';
import { ProjectJobStatus } from '@marxan-api/modules/projects/job-status/project-status.view.api.entity';

export { Status };

Expand All @@ -26,17 +28,33 @@ export interface Scenario {
jobs: AnyJob[];
}

export interface Project {
jobs: AnyJob[];
}

export interface ProjectWithScenarios {
scenarios: Scenario[];
project: Project;
}

@Injectable()
export class JobStatusService {
constructor(
@InjectRepository(ScenarioJobStatus)
private readonly statusRepository: Repository<ScenarioJobStatus>,
@InjectRepository(ProjectJobStatus)
private readonly projectStatusRepository: Repository<ProjectJobStatus>,
) {}

async getJobStatusFor(projectId: string): Promise<Scenario[]> {
async getJobStatusFor(projectId: string): Promise<ProjectWithScenarios> {
const statuses = await this.statusRepository.find({
projectId,
});
const projectJobs = await this.projectStatusRepository.find({
where: {
projectId,
},
});
type ScenarioId = string;
const groupedStatuses: Record<ScenarioId, Scenario> = {};
for (const status of statuses) {
Expand All @@ -53,6 +71,20 @@ export class JobStatusService {
});
}

return Object.values(groupedStatuses);
return {
scenarios: Object.values(groupedStatuses),
project: {
jobs: projectJobs.filter(this.#hasJobStatus).map((job) => ({
kind: job.jobType,
status: job.jobStatus!, // guard, or even types, do not play well
// with getters
data: job.data,
})),
},
};
}

#hasJobStatus = (
job: ProjectJobStatus,
): job is Required<ProjectJobStatus, 'jobStatus'> => isDefined(job.jobStatus);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ViewColumn, ViewEntity } from 'typeorm';
import { JobType } from '@marxan-api/modules/projects/job-status/jobs.enum';
import { JobStatus } from '@marxan-api/modules/scenarios/scenario.api.entity';
import { ApiEvent } from '@marxan-api/modules/api-events/api-event.api.entity';
import { API_EVENT_KINDS, ProjectEvents } from '@marxan/api-events';
import { ValuesType } from 'utility-types';

@ViewEntity({
expression: `
SELECT DISTINCT ON (job_type, topic) job_type,
api_events.topic AS project_id,
api_events.kind,
api_events.data
FROM api_events
CROSS JOIN LATERAL SUBSTRING(
api_events.kind
FROM
'project.#"[^.]*#"%' FOR '#'
) AS job_type
ORDER BY job_type,
api_events.topic,
api_events.timestamp DESC
`,
})
export class ProjectJobStatus {
@ViewColumn({
name: 'job_type',
})
jobType!: JobType;

@ViewColumn()
kind!: Extract<API_EVENT_KINDS, `project.${string}`>;

get jobStatus(): JobStatus | undefined {
return eventToJobStatusMapping[this.kind];
}

@ViewColumn({
name: 'project_id',
})
projectId!: string;

@ViewColumn()
data!: ApiEvent['data'];
}

const eventToJobStatusMapping: Record<ValuesType<ProjectEvents>, JobStatus> = {
[API_EVENT_KINDS.project__grid__failed__v1__alpha]: JobStatus.failure,
[API_EVENT_KINDS.project__grid__finished__v1__alpha]: JobStatus.done,
[API_EVENT_KINDS.project__grid__submitted__v1__alpha]: JobStatus.running,
[API_EVENT_KINDS.project__protectedAreas__failed__v1__alpha]:
JobStatus.failure,
[API_EVENT_KINDS.project__protectedAreas__finished__v1__alpha]:
JobStatus.done,
[API_EVENT_KINDS.project__protectedAreas__submitted__v1__alpha]:
JobStatus.running,
};
14 changes: 10 additions & 4 deletions api/apps/api/src/modules/projects/projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,16 @@ export class ProjectsController {
@Param('id') projectId: string,
@Req() req: RequestWithAuthenticatedUser,
): Promise<ProjectJobsStatusDto> {
const scenarios = await this.projectsService.getJobStatusFor(projectId, {
authenticatedUser: req.user,
});
return this.jobsStatusSerizalizer.serialize(projectId, scenarios);
const projectWithScenarios = await this.projectsService.getJobStatusFor(
projectId,
{
authenticatedUser: req.user,
},
);
return this.jobsStatusSerizalizer.serialize(
projectId,
projectWithScenarios,
);
}

@ApiConsumesShapefile({ withGeoJsonResponse: false })
Expand Down
2 changes: 2 additions & 0 deletions api/apps/api/src/modules/projects/projects.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ProjectSerializer } from './dto/project.serializer';
import { JobStatusSerializer } from './dto/job-status.serializer';
import { JobStatusService } from './job-status/job-status.service';
import { ScenarioJobStatus } from './job-status/job-status.view.api.entity';
import { ProjectJobStatus } from './job-status/project-status.view.api.entity';
import { PlanningAreasModule } from './planning-areas';
import { UsersProjectsApiEntity } from './control-level/users-projects.api.entity';
import { ProjectsListingController } from './projects-listing.controller';
Expand All @@ -35,6 +36,7 @@ import { PlanningUnitGridModule } from './planning-unit-grid';
TypeOrmModule.forFeature([
Project,
ScenarioJobStatus,
ProjectJobStatus,
UsersProjectsApiEntity,
]),
UsersModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ScenarioJobStatus } from '@marxan-api/modules/projects/job-status/job-s
import { ApiEvent } from '@marxan-api/modules/api-events/api-event.api.entity';
import {
JobStatusService,
Scenario,
ProjectWithScenarios,
} from '@marxan-api/modules/projects/job-status';
import { bootstrapApplication } from '../utils/api-application';
import { GivenUserIsLoggedIn } from '../steps/given-user-is-logged-in';
Expand All @@ -26,7 +26,7 @@ afterEach(async () => {
});

describe(`when has two projects with scenarios and events`, () => {
let result: Scenario[];
let result: ProjectWithScenarios;
let scenarioIds: string[];
let projectId: string;
beforeEach(async () => {
Expand All @@ -40,7 +40,7 @@ describe(`when has two projects with scenarios and events`, () => {
});

it(`should return last events of every job for scenarios of the project`, () => {
expect(result).toEqual(
expect(result.scenarios).toEqual(
[
{
jobs: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,37 +18,38 @@ afterAll(async () => {
await app.close();
});

describe(`Given scenario has some jobs running`, () => {
let result: request.Response;
beforeAll(async () => {
// Asset
await world.GivenCostSurfaceFinished();
await world.GivenScenarioPlanningInclusionInProgress();
test(`job statuses for project`, async () => {
await world.GivenCostSurfaceFinished();
await world.GivenScenarioPlanningInclusionInProgress();
await world.GivenGridSettingInProgress();

// Act
result = await world.WhenGettingProjectJobsStatus();
});
const result = await world.WhenGettingProjectJobsStatus();

it(`should contain only pending job`, () => {
expect(result.body.data.attributes.scenarios).toEqual([
{
id: world.scenarioIdWithCostSurfaceFinished(),
jobs: [
{
kind: 'costSurface',
status: 'done',
},
],
},
{
id: world.scenarioIdWithPendingJob(),
jobs: [
{
kind: 'planningUnitsInclusion',
status: 'running',
},
],
},
]);
});
expect(result.body.data.attributes.jobs).toEqual([
{
data: null,
kind: 'grid',
status: 'running',
},
]);
expect(result.body.data.attributes.scenarios).toEqual([
{
id: world.scenarioIdWithCostSurfaceFinished(),
jobs: [
{
kind: 'costSurface',
status: 'done',
},
],
},
{
id: world.scenarioIdWithPendingJob(),
jobs: [
{
kind: 'planningUnitsInclusion',
status: 'running',
},
],
},
]);
});
6 changes: 6 additions & 0 deletions api/apps/api/test/project-jobs-status/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export const createWorld = async (app: INestApplication) => {
projectId,
scenarioIdWithPendingJob: () => scenarioIdWithPendingJob,
scenarioIdWithCostSurfaceFinished: () => scenarioIdWithCostSurfaceFinished,
GivenGridSettingInProgress: async () =>
GivenApiEvent(
app,
projectId,
API_EVENT_KINDS.project__grid__submitted__v1__alpha,
),
GivenScenarioPlanningInclusionInProgress: async () => {
const scenario = await GivenScenarioExists(app, projectId, token);
scenarios.push(scenario.id);
Expand Down
5 changes: 5 additions & 0 deletions api/libs/api-events/src/api-event-kinds.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export enum API_EVENT_KINDS {
scenario__planningAreaProtectedCalculation__failed__v1__alpha1 = 'scenario.planningAreaProtectedCalculation.failed/v1/alpha',
}

export type ProjectEvents = Pick<
typeof API_EVENT_KINDS,
Extract<keyof typeof API_EVENT_KINDS, `project__${string}`>
>;

export type ScenarioEvents = Pick<
typeof API_EVENT_KINDS,
Extract<keyof typeof API_EVENT_KINDS, `scenario__${string}`>
Expand Down

0 comments on commit 6cf28ba

Please sign in to comment.