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
65 changes: 57 additions & 8 deletions backend/src/database/migrate-in-database.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { ExerciseState, Mutable, UUID } from 'digital-fuesim-manv-shared';
import type {
ExerciseAction,
ExerciseState,
Mutable,
UUID,
} from 'digital-fuesim-manv-shared';
import { applyMigrations } from 'digital-fuesim-manv-shared';
import type { EntityManager } from 'typeorm';
import { RestoreError } from '../utils/restore-error';
Expand Down Expand Up @@ -27,16 +32,17 @@ export async function migrateInDatabase(
order: { index: 'ASC' },
})
).map((action) => JSON.parse(action.actionString));

const {
newVersion,
migratedProperties: { currentState, history },
} = applyMigrations(exercise.stateVersion, {
currentState: loadedCurrentState,
history: {
initialState: loadedInitialState,
actions: loadedActions,
},
});
} = migrateWithFallback(
exercise,
loadedCurrentState,
loadedInitialState,
loadedActions
);

const initialState: Mutable<ExerciseState> =
history?.initialState ?? currentState;
const actions = history?.actions ?? [];
Expand Down Expand Up @@ -107,3 +113,46 @@ export async function migrateInDatabase(
.execute();
}
}
function migrateWithFallback(
exercise: ExerciseWrapperEntity,
loadedCurrentState: any,
loadedInitialState: any,
loadedActions: any[]
): {
newVersion: any;
migratedProperties: {
currentState: Mutable<ExerciseState>;
history:
| {
initialState: Mutable<ExerciseState>;
actions: (Mutable<ExerciseAction> | null)[];
}
| undefined;
};
} {
try {
return applyMigrations(
exercise.stateVersion,
{
currentState: loadedCurrentState,
history: {
initialState: loadedInitialState,
actions: loadedActions,
},
},
'complete-history'
);
} catch {
return applyMigrations(
exercise.stateVersion,
{
currentState: loadedCurrentState,
history: {
initialState: loadedInitialState,
actions: loadedActions,
},
},
'current'
);
}
}
9 changes: 5 additions & 4 deletions backend/src/exercise/http-handler/api/exercise.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {
ExerciseIds,
ExerciseTimeline,
StateExport,
StateImportBody,
} from 'digital-fuesim-manv-shared';
import { ExerciseState } from 'digital-fuesim-manv-shared';
import { isEmpty } from 'lodash-es';
Expand All @@ -14,21 +14,22 @@ import type { HttpResponse } from '../utils';

export async function postExercise(
databaseService: DatabaseService,
importObject: StateExport
{ stateExport, historyImportStrategy }: StateImportBody
): Promise<HttpResponse<ExerciseIds>> {
try {
const participantId = UserReadableIdGenerator.generateId();
const trainerId = UserReadableIdGenerator.generateId(8);
const newExerciseOrError = isEmpty(importObject)
const newExerciseOrError = isEmpty(stateExport)
? ExerciseWrapper.create(
participantId,
trainerId,
databaseService,
ExerciseState.create(participantId)
)
: await importExercise(
importObject,
stateExport,
{ participantId, trainerId },
{ historyImportStrategy },
databaseService
);
if (!(newExerciseOrError instanceof ExerciseWrapper)) {
Expand Down
15 changes: 7 additions & 8 deletions backend/src/utils/import-exercise.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { plainToInstance } from 'class-transformer';
import type { ExerciseIds } from 'digital-fuesim-manv-shared';
import type {
ExerciseIds,
HistoryImportStrategy,
} from 'digital-fuesim-manv-shared';
import {
migrateStateExport,
ReducerError,
Expand All @@ -13,22 +16,18 @@ import type { HttpResponse } from '../exercise/http-handler/utils';
export async function importExercise(
importObject: StateExport,
ids: ExerciseIds,
options: { historyImportStrategy: HistoryImportStrategy },
databaseService: DatabaseService
): Promise<ExerciseWrapper | HttpResponse<ExerciseIds>> {
const migratedImportObject = migrateStateExport(importObject);
// console.log(
// inspect(importObject.history, { depth: 2, colors: true })
// );
const migratedImportObject = migrateStateExport(importObject, options);
const importInstance = plainToInstance(
StateExport,
migratedImportObject
// TODO: verify that this is indeed not required
// // Workaround for https://github.com/typestack/class-transformer/issues/876
// { enableImplicitConversion: true }
);
// console.log(
// inspect(importInstance.history, { depth: 2, colors: true })
// );

const validationErrors = validateExerciseExport(importInstance);
if (validationErrors.length > 0) {
return {
Expand Down
5 changes: 4 additions & 1 deletion benchmark/src/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ export class StepState {
export const steps: Step<StepState>[] = [
new BenchmarkStep(
'migrate',
({ data }) => migrateStateExport(data) as StateExport
({ data }) =>
migrateStateExport(data, {
historyImportStrategy: 'complete-history',
}) as StateExport
),
new BenchmarkStep('validateExercise', ({ migrate: migratedValues }) =>
validateExerciseExport(migratedValues!.value)
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/app/core/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { Store } from '@ngrx/store';
import type {
ExerciseIds,
ExerciseTimeline,
HistoryImportStrategy,
StateExport,
StateImportBody,
} from 'digital-fuesim-manv-shared';
import { freeze } from 'immer';
import { lastValueFrom } from 'rxjs';
Expand All @@ -30,11 +32,18 @@ export class ApiService {
);
}

public async importExercise(exportedState: StateExport) {
public async importExercise(
exportedState: StateExport,
historyImportStrategy: HistoryImportStrategy
) {
const body: StateImportBody = {
stateExport: exportedState,
historyImportStrategy,
};
return lastValueFrom(
this.httpClient.post<ExerciseIds>(
`${httpOrigin}/api/exercise`,
exportedState
body
)
);
}
Expand Down
104 changes: 104 additions & 0 deletions frontend/src/app/core/exercise-import.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Injectable } from '@angular/core';
import { plainToInstance } from 'class-transformer';
import type {
Constructor,
ExerciseIds,
ExportImportFile,
HistoryImportStrategy,
} from 'digital-fuesim-manv-shared';
import { PartialExport, StateExport } from 'digital-fuesim-manv-shared';
import { BehaviorSubject, Subject } from 'rxjs';
import { ApiService } from './api.service';
import { MessageService } from './messages/message.service';

@Injectable({
providedIn: 'root',
})
export class ExerciseImportService {
public readonly selectedOption$ =
new BehaviorSubject<HistoryImportStrategy>('complete-history');
public readonly currentlyImporting$ = new BehaviorSubject<boolean>(false);
public readonly ids$ = new Subject<ExerciseIds>();
public historyImportStrategy: HistoryImportStrategy = 'complete-history';

constructor(
private readonly apiService: ApiService,
private readonly messageService: MessageService
) {}

public async createExercise() {
this.apiService
.createExercise()
.then((ids) => {
this.ids$.next(ids);
this.messageService.postMessage(
{
title: 'Übung erstellt',
body: 'Sie können nun der Übung beitreten.',
color: 'success',
},
'toast'
);
})
.catch((error) => {
this.messageService.postError({
title: 'Fehler beim Erstellen der Übung',
error: error.message,
});
});
}

public async importExercise(file: File | null) {
this.currentlyImporting$.next(true);
try {
const importString = await file?.text();
if (importString === undefined) {
return;
}
const importPlain = JSON.parse(importString) as ExportImportFile;
const type = importPlain.type;
if (!['complete', 'partial'].includes(type)) {
throw new Error(`Ungültiger Dateityp: \`type === ${type}\``);
}
const importInstance = plainToInstance(
(type === 'complete'
? StateExport
: PartialExport) as Constructor<
PartialExport | StateExport
>,
importPlain
);
switch (importInstance.type) {
case 'complete': {
const ids = await this.apiService.importExercise(
importInstance,
this.historyImportStrategy
);
this.ids$.next(ids);

this.messageService.postMessage(
{
color: 'success',
title: 'Übung importiert',
body: 'Sie können nun der Übung beitreten',
},
'toast'
);
break;
}
case 'partial': {
throw new Error(
'Dieser Typ kann zur Zeit nicht importiert werden.'
);
}
}
} catch (error: unknown) {
this.messageService.postError({
title: 'Fehler beim Importieren der Übung',
error,
});
} finally {
this.currentlyImporting$.next(false);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ export class TimeTravelComponent implements OnDestroy {
this.store
);
const { trainerId } = await this.apiService.importExercise(
new StateExport(cloneDeepMutable(currentExerciseState))
new StateExport(cloneDeepMutable(currentExerciseState)),
'complete-history'
);
this.messageService.postMessage({
color: 'success',
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/app/pages/landing-page/landing-page.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { CommonModule } from '@angular/common';

import { FormsModule } from '@angular/forms';
import { SharedModule } from 'src/app/shared/shared.module';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { LandingPageComponent } from './landing-page/landing-page.component';

@NgModule({
declarations: [LandingPageComponent],
imports: [CommonModule, FormsModule, SharedModule],
imports: [CommonModule, FormsModule, SharedModule, NgbDropdownModule],
exports: [LandingPageComponent],
})
export class LandingPageModule {}
Loading